← Back to blog

Build Tooling for a Trading SPA: Vite, SWC, and Why Not Next.js

Why a real-time trading terminal is a client-side SPA, and how Vite plus the SWC plugin keeps builds and hot reload fast.

Oliver Benns
Oliver Benns

Software engineer · Creator of Hedge UI


Almost every "which React framework should I use?" debate skips the first question that actually matters: what does your app need to render, and when? A marketing page wants its HTML on the wire before any JavaScript runs, because crawlers and first-time visitors judge it on the initial response. A logged-in trading terminal is the opposite. Nobody indexes it, the screen is empty until a websocket connects, and the data is obsolete milliseconds after it arrives. Those two products have almost nothing in common at the rendering layer, yet teams routinely reach for the same server-rendering framework for both out of habit.

The Hedge UI demo at demo.hedgeui.com is a client-side single-page application built with Vite 7.2.6 and the SWC React plugin. The marketing site you are reading this on is Next.js 15 with the App Router. That split is deliberate, and this post walks through the reasoning: why server rendering buys a trading terminal nothing, what it would cost, and how Vite's dev server, native ESM, and the SWC transform keep the build fast without dragging a Node runtime into the request path.

SSR, SSG, and SPA: what each is actually for

Three rendering strategies, three different problems they solve:

  • SSG (static site generation) runs your render at build time and ships HTML files. Perfect for content that is the same for everyone and changes rarely: docs, blogs, landing pages.
  • SSR (server-side rendering) runs your render per request on a server, then hydrates on the client. You reach for it when the page is dynamic and you still need crawlable HTML or a fast first paint of real content.
  • SPA (single-page application) ships an HTML shell plus a JavaScript bundle. The browser renders everything. The server is a static file host (or a CDN) with nothing to compute.

The question is never "which is best" in the abstract. It is "what does the first byte need to contain?" For a marketing page the answer is the rendered content, so SSG or SSR wins. For an authenticated trading screen the answer is "an empty shell, as fast as possible" and that is exactly what an SPA gives you.

Why a trading terminal gains nothing from SSR

Walk through what SSR is supposed to buy you and check each one against a trading app:

SEO. There is none. The terminal lives behind a login. Googlebot never sees it and would not know what to do with a live order book if it did. The crawlable surface is the marketing site, which is a separate Next.js project. We cover the search-traffic side of the business in why a starter kit shortens time to market for an exchange; the app itself contributes zero organic keywords by design.

Meaningful first paint. SSR earns its keep when the server can render content the user wants to read immediately. A trading terminal has no such content. The order book, the trades feed, the price chart - all of it streams in over a websocket after the page loads. In the demo, src/binance/binance-stream.ts opens a connection to wss://stream.binance.com (or wss://stream.binance.us:9443 for US clients) and the panels stay empty until ticks arrive. Server-rendering an empty order book is theatre. The honest first paint is the app shell: a header and an empty panel grid, which is cheap to produce on the client.

Personalisation. SSR helps when the server knows who you are and can tailor the document. Here the personalised state is the saved layout, favourites, and theme, all of which live in localStorage and are read on the client. There is nothing for a server to inject, and any attempt to inject it would mismatch on hydration.

So SSR solves three problems the terminal does not have. What would it cost to adopt anyway?

  • A Node server in the hot path. An SPA deploys as static files behind a CDN. Add SSR and every page load now executes your render code on a server you have to run, scale, and keep alive. That is a new failure mode for an app whose whole value proposition is staying responsive.
  • Hydration complexity. Server-rendered markup has to match the client's first render exactly or React throws hydration mismatches. The moment your UI depends on window, localStorage, or the current time - and a trading UI depends on all three - you are writing useEffect guards and suppressHydrationWarning to paper over the gap.
  • Streaming data during SSR. What does the server render for a value that only exists after a websocket handshake? Either it renders a placeholder (in which case the client re-renders everything immediately, and you paid for SSR to produce throwaway HTML) or it tries to hold the request open waiting for live data, which is a great way to time out a request.
  • A coupled build and deploy. An SPA's build output is just files; you can host last week's bundle and this week's bundle side by side and roll back by flipping a pointer. An SSR app couples the artifact to a running runtime version, so rollbacks and canaries involve redeploying server processes, not swapping static files.

None of that is a knock on SSR. It is the right tool when the document needs to be meaningful before JavaScript runs. A logged-in, real-time, client-driven app is simply not that case.

Why the marketing site still uses Next.js

Here is the candid part, because picking Vite for the app is only credible if you admit where the other tool wins. This blog is a Next.js 15 App Router project, and that is the correct choice for it. The marketing site's entire job is to be found: every post needs server-rendered HTML, a populated <title> and meta description, Open Graph tags, and a sitemap, all present in the first response so crawlers and link previewers see real content. Next.js gives that for free with file-based routing, generateMetadata, and static generation of the blog at build time.

The post you are reading is a markdown file with gray-matter frontmatter, rendered to static HTML at build time and listed in a generated sitemap. None of that machinery would make sense inside the trading app, and none of the trading app's machinery - the websocket clients, the FlexLayout panel grid, the localStorage-backed layout state - would make sense as server-rendered routes. The two repos do not share a build precisely because they do not share a rendering need.

Run the same logic backwards and you get the demo. No SEO, no shared content, no first-paint payload worth pre-rendering. Forcing the app into Next.js would mean carrying an SSR runtime to serve what is functionally a static shell, plus the hydration tax on a UI that is almost entirely client state. Two products, two rendering needs, two tools. That is the whole thesis.

ConcernMarketing siteTrading terminal
AudienceAnonymous, search-drivenAuthenticated, logged-in
IndexableYes, that is the pointNo, behind a login
First-byte contentRendered article HTMLApp shell only
Data sourceBuild-time markdownLive websocket ticks
PersonalisationNonelocalStorage layout/theme
Right toolNext.js 15 (SSG/SSR)Vite SPA

Where the line could move

To be fair to SSR, the boundary is not permanent. If the product grew a public, shareable surface - a read-only portfolio page you can send to a client, or a market-overview page meant to rank in search - that surface would have real SSR requirements and would belong on the server-rendered side of the house, either as new routes on the marketing site or behind its own renderer. The point is not "SPAs always, SSR never". It is that you scope the rendering strategy to the surface, and today the entire authenticated terminal is one surface with no server-rendering need. The day a genuinely indexable, sharable view appears, it gets the tool that fits it, rather than retrofitting the whole app to a runtime it does not use.

Vite specifics: dev server, native ESM, instant HMR

The day-to-day reason to like Vite is the dev server. There is no bundling step before you can open the app. Vite serves your source over native ES modules and lets the browser request only the modules the current page touches, transforming each on demand. Cold start is effectively instant regardless of how big the project grows, and the demo is not tiny: roughly 8,220 lines across 82 files.

Hot module replacement is the part you feel every minute. Save a panel component and Vite swaps that single module in place while React Fast Refresh preserves component state. Editing the order form does not reset the limit price you typed or tear down the websocket connection feeding the order book next to it. On a real-time UI that is not a nicety; reconnecting a socket and replaying state on every keystroke would make the dev loop unusable, and the lag would mask the very latency problems you are trying to observe.

The whole project is ESM. package.json declares "type": "module", and the TypeScript config targets ES2022 with ESNext modules and "moduleResolution": "bundler". There is no CommonJS interop layer to reason about, and import.meta.env is the native way to read build-time config.

Environment config without a server

One thing an SPA forces you to be honest about: any config the client reads is public. Vite only exposes variables prefixed with VITE_ to client code, which is a useful guardrail - it makes it hard to leak a secret by accident, because anything without the prefix simply is not in the bundle. The demo reads its PostHog analytics keys this way in src/main.tsx:

const postHogOptions: Partial<PostHogConfig> = { api_host: import.meta.env.VITE_PUBLIC_POSTHOG_HOST, defaults: "2025-05-24", capture_exceptions: true, };

These are public, ship-in-the-bundle values by design. Anything genuinely secret - a signing key, a private API credential - never belongs in an SPA at all; it lives behind whatever API the terminal talks to. SSR muddies this line, because a server can read true secrets at render time and it is easy to forget which side of the boundary a value is on. A pure client build removes the ambiguity: if it is in the code, it is public.

Dependency pre-bundling

Native ESM in the browser falls over if a single dependency ships hundreds of internal modules, because the browser would fire a request per file. Vite handles this by pre-bundling node_modules dependencies with esbuild on first run and caching the result in node_modules/.vite. A package like @tanstack/react-table or recharts collapses into one or a few module requests instead of dozens. The cache is keyed on your lockfile and config, so it only rebuilds when dependencies actually change. The upshot is that the dev server stays fast even though the demo pulls in a fairly heavy dependency set: Radix primitives, FlexLayout for the panel grid, decimal.js for order maths, and the charting libraries.

SWC versus Babel for the React transform

The React transform - turning JSX into createElement/jsx calls and wiring up Fast Refresh - is the hottest path in the toolchain because it runs on every .tsx file on every edit. The demo uses @vitejs/plugin-react-swc 3.11.0, which delegates that transform to SWC (a Rust compiler) instead of the default Babel-based plugin.

The practical difference is speed on large refactors and cold reloads, with one less JavaScript-on-JavaScript compiler in the loop. SWC handles the JSX transform and Fast Refresh; TypeScript's own compiler handles type checking separately (more on that below). It is worth being precise about what SWC does and does not do here:

@vitejs/plugin-react (Babel)@vitejs/plugin-react-swc
Transform engineBabel (JavaScript)SWC (Rust)
JSX + Fast RefreshYesYes
Type checkingNo (Vite strips types)No (Vite strips types)
Babel plugin ecosystemFull accessLimited
Typical edgeCustom Babel pluginsRaw transform speed

The trade-off is real: if you depend on a niche Babel plugin, the SWC plugin will not run it. The demo does not, so the faster transform is a clean win. One caveat worth flagging for the React 19 era - if you adopt the React Compiler, its Babel plugin currently lives in the Babel toolchain, which complicates an all-SWC setup. We weigh that decision in trading applications in the React Compiler era.

A minimal config

The entire Vite config for the demo fits on one screen. Two plugins, a path alias, and the test block:

import path from "path"; import tailwindcss from "@tailwindcss/vite"; import { defineConfig } from "vitest/config"; import react from "@vitejs/plugin-react-swc"; import { playwright } from "@vitest/browser-playwright"; export default defineConfig({ plugins: [react(), tailwindcss()], resolve: { alias: { "@": path.resolve(__dirname, "./src"), }, }, test: { css: true, browser: { provider: playwright(), enabled: true, instances: [{ browser: "chromium" }], }, setupFiles: ["./test/setup.ts"], }, });

Tailwind CSS 4.1.17 plugs in as a first-class Vite plugin via @tailwindcss/vite rather than a PostCSS step, so styles go through the same fast pipeline as everything else. The @ alias maps to ./src and is mirrored in tsconfig.app.json so the editor and the bundler agree.

The HTML entry point is equally plain. index.html is the actual entry (Vite treats it as the root of the dependency graph), and it contains a single mount node and a module script:

{ "dev": "vite", "build": "tsc -b && vite build", "check-types": "tsc --noEmit", "lint": "eslint .", "preview": "vite preview", "test": "vitest --browser.headless" }

Note that build runs tsc -b first and only then vite build. Vite itself strips TypeScript types without checking them, which is what makes the dev server fast, so type safety is enforced as a separate gate before the production bundle is emitted. The tsconfig.app.json is strict on purpose: strict, noUnusedLocals, noUnusedParameters, erasableSyntaxOnly, and verbatimModuleSyntax all on. A build fails on an unused import, which keeps the 82-file tree honest.

Build output and code-splitting

vite build runs Rollup under the hood and emits a hashed asset bundle. A recent production build of the demo produced a single index-[hash].js of about 1.3 MB and an index-[hash].css of about 61 KB, referenced from a generated dist/index.html. The hashed filenames mean you can cache them forever and bust the cache on every deploy by content.

$ pnpm build $ tree dist dist ├── assets │ ├── index-Bx7itZEC.css # ~61 KB, the whole Tailwind layer │ └── index-rNb2QrSh.js # ~1.3 MB, app + dependencies └── index.html # references the hashed assets above

The entire output is static. There is no server.js, no Node process, nothing to run - you copy dist/ to a CDN or object store and you are done.

That single large JS chunk is worth being candid about. With no build.rollupOptions.manualChunks configured, Rollup puts the app and its dependencies into one entry chunk. For a tool sold as a starter kit that is a reasonable default - it keeps the mental model simple and there is no route-level navigation to split on, since the terminal is one screen made of panels. But it is a knob, not a law. Heavy, optional dependencies are the obvious split candidates. The market chart panel pulls in the TradingView widget (via react-ts-tradingview-widgets), which not every layout shows; lazy-loading it with React.lazy and a dynamic import keeps it out of the initial bundle for users who never open that panel. The pattern is the same one we use to keep render work off the critical path in performant React trading applications.

import { lazy, Suspense } from "react"; const MarketChartPanel = lazy(() => import("@/features/market-chart/market-chart-panel").then((m) => ({ default: m.MarketChartPanel, })), ); // rendered behind <Suspense fallback={<Spinner />}> only when the panel is mounted

Testing in a real browser

A trading UI is mostly DOM behaviour: does the order form parse a limit price, does the order book paint the right side, does a number format with the correct precision. Testing that against a simulated DOM like jsdom leaves gaps, so the demo runs Vitest 4.0.15 in a real browser through @vitest/browser-playwright and Playwright 1.57.0, with vitest-browser-react 2.0.2 supplying the React render helpers.

The payoff is that the same Vite config powers both dev and test, so there is no second build pipeline to keep in sync. Components render in Chromium and assertions run against the real DOM and real layout. A snippet from src/features/order-form/order-form.test.tsx:

import { render } from "vitest-browser-react"; import { expect, test } from "vitest"; import { OrderForm } from "./order-form"; test("should render order form", async () => { const screen = await render(<OrderForm {...defaultProps} />); const inputs = screen.getByRole("spinbutton"); expect(inputs).toHaveLength(2); expect(inputs.nth(0)).toHaveValue(50000); const totalInput = screen.getByRole("textbox").nth(0); expect(totalInput).toHaveValue("≈ 50000"); });

test/setup.ts adds the dark class to document.body before each suite, because the app is dark-mode only and a chunk of the styling is conditional on it. Running in a real browser means that class actually affects computed styles, the way it does in production.

The same suite runs two ways. pnpm test invokes vitest --browser.headless for CI, while pnpm test:ui drops the headless flag so you can watch the runner drive a visible Chromium window during development. Both share one config, so there is no "works in CI, fails locally" gap from divergent test environments. The deeper challenge - asserting on components fed by a live stream - is its own topic, covered in testing real-time React components.

Preview and deploy: static all the way down

vite preview serves the production dist/ locally so you can sanity-check the real bundle before it ships, which catches the class of bugs that only appear after minification and tree-shaking. In production the deploy story is the simplest one available: upload static files. The only configuration a single-page app needs is a fallback rewrite, so that any deep link resolves to index.html and the client takes over routing rather than the host returning a 404. On most static hosts that is one line:

{ "rewrites": [{ "source": "/(.*)", "destination": "/index.html" }] }

Compare that to operating an SSR deployment for the same app: a long-running Node process, a health check, autoscaling, and a render path that can fail at request time. For a screen whose first byte is an empty shell, that is operational cost with no user-facing payoff. The SPA's worst-case failure is "the CDN served stale but cached JavaScript", which is recoverable and observable. An SSR app adds "the render server fell over" to the list of ways the login page can break.

Closing: pick the tool to the rendering need

The build-tooling decision is downstream of one question: what does the first response need to contain? If it is content a human or a crawler should read immediately, you want it server-rendered, and Next.js earns its complexity - which is exactly why this marketing site runs on it. If the first response is an app shell that comes alive only after the client connects, an SPA is not a compromise, it is the honest shape of the product, and Vite plus the SWC plugin gives you that with a near-instant dev loop and no Node runtime in the request path.

The failure mode is treating the framework choice as an identity rather than a fit. Plenty of trading-UI projects bolt on SSR they never use and then spend months fighting hydration; we catalogue that and other avoidable missteps in what trading UI projects get wrong. Match the renderer to what actually needs rendering, keep your client state where the data lives (see Context plus React Query for trading state), and the tooling stops being a debate and starts being obvious.

For Hedge UI that worked out to two repos: a Next.js site whose job is to be crawled, and an 8,220-line Vite SPA whose job is to connect a websocket and stay out of the way. Same React, same TypeScript, two rendering strategies, because they are answering two different questions. If you can state in one sentence what your app's first byte needs to contain, you have already chosen your build tool - the rest is configuration.

Kickstart Your Trading Application

Hedge UI is a React starter kit with production-ready trading components, real-time data handling, and customisable layouts — so you can ship faster.

Get Hedge UI