Vite SSR and SSG Integration

Vite’s server-side rendering (SSR) and static site generation (SSG) workflows split a single application into two bundles compiled from one source tree, then reconcile them at hydration time. This section isolates the dual-bundle architecture, ssrLoadModule transform pipeline, and ssr.external/ssr.noExternal resolution rules that production deployments depend on; for the broader configuration surface that feeds these settings, start from Vite Configuration & Ecosystem before tuning the server graph. The core discipline is keeping browser-only globals out of the Node execution path while still emitting an HTML payload the client can hydrate without re-rendering from scratch.

The problem exists because a browser and a Node process do not share a runtime. The browser has window, document, localStorage, and a real event loop tied to a rendered DOM; Node has none of those, but it does have the filesystem, process, and synchronous module resolution that the browser lacks. A single-page application ignores this asymmetry by assuming the browser is the only place code runs. SSR breaks that assumption: the exact same component that renders a chart on the client must also produce a meaningful string on a machine where window throws a ReferenceError the instant it is touched at module-evaluation time. Vite’s answer is not to sand down the differences but to compile the source twice — once for each runtime — and to give you explicit knobs (ssr.external, ssr.noExternal, ssr.resolve.conditions) for deciding, per dependency, which runtime’s rules apply.

Where this sits in the build pipeline matters. In development, requests hit Vite’s middleware, get transformed on demand, and are served without a bundle step; in production, vite build runs Rollup twice to emit a client bundle plus a server bundle, and SSG runs the server bundle once at build time to freeze HTML to disk. Getting the boundaries wrong does not fail loudly at config time — it fails at the first server render, or worse, only in production when a dependency that resolved fine in dev turns out to have no node export condition. The rest of this section walks the mechanism behind each knob, then the failure modes in the order you will actually hit them.

Vite SSR request flow and dependency externalization A request enters the Node server, ssrLoadModule renders HTML on the server, the browser receives markup and then hydrates, while ssr.external and ssr.noExternal split server dependencies. SSR request flow Server render GET /route ssrLoadModule entry-server.ts HTML string + state JSON Client hydrate entry-client.ts Server dependency resolution ssr.external left as import, Node resolves at runtime ssr.noExternal inlined into the server bundle by Rollup ssr.resolve.conditions: ['node'] picks server export maps for both halves
Figure: the SSR request flow from server render through hydration, with ssr.external/noExternal deciding which dependencies stay imports versus get inlined.

Problem Statement

The SSR/SSG model exists because a single-page application that ships only <div id="app"></div> pays for an empty first paint, broken crawlability, and a hydration cost that cannot be amortized. Vite solves this by rendering the component tree to an HTML string on the server, serializing the fetched data alongside it, and shipping a client bundle that adopts the existing DOM rather than recreating it. The failure modes are specific: browser globals leaking into Node, dependencies double-bundled because ssr.external was misjudged, and hydration mismatches when server and client render diverge. The first concrete trap—diverging output—has its own guide at Fixing Hydration Mismatch Errors in Vite SSR.

Unpack what “adopts the existing DOM” means, because it is the constraint every other decision bends around. When the client bundle boots, the DOM already contains the server’s markup. Hydration does not paint a new tree; it walks the server-rendered nodes in the same order the component tree would produce them, attaches event listeners, and reconstructs the framework’s internal fiber or vnode state without mutating the elements. That is far cheaper than a cold render — no layout thrash, no reflow — but it is only correct if the client’s first render is byte-identical in structure to what the server emitted. The moment the two diverge, React 18 throws away the server DOM for the affected subtree and re-renders from scratch on the client, which erases the entire performance argument for SSR and usually produces a visible flash. So the serialized state you ship in that inline <script> is not a convenience; it is the mechanism that guarantees the client renders the same tree the server did, because it feeds the client the identical data the server fetched instead of letting the client refetch and possibly get a different answer.

The second cost the SPA model hides is discovery latency. A client-only app must download and parse its JavaScript before it can even discover which data or code-split chunks a route needs, so the critical path is serial: HTML, then JS, then a data fetch, then paint. SSR collapses the first three into a single response — markup, state, and preload hints arrive together — which is why the manifest-driven preload described later is not a micro-optimization but a structural change to the request waterfall. Understanding the SPA’s serial waterfall is what makes every later trade-off legible: each Vite knob is trading a slice of that waterfall for a slice of server compute.

Empty SPA shell versus server-rendered HTML A client-only SPA ships an empty app div, giving a blank first paint and nothing for crawlers; an SSR response ships real markup plus serialized state that the client adopts by hydration rather than re-rendering. client-only SPA ships <div id="app"></div> blank first paint nothing for crawlers full client re-render SSR response real markup + state JSON meaningful first paint crawlable content client adopts the DOM (hydrate)
Figure: SSR trades a Node render pass for a paint-ready, crawlable payload the client hydrates in place.

Prerequisites

Dual entry points from one source tree One source tree feeds two entries: entry-client calls hydrateRoot or mount in the browser, and entry-server exports an async render(url) that returns HTML for Node; both need Node 20.10 plus a framework plugin and its server renderer. src/ (one tree)shared components entry-client.tshydrateRoot / mount entry-server.tsasync render(url) Two entries, one source — Node 20.10+, framework plugin + server renderer
Figure: the client/server split is a hard prerequisite — two entries compiled from the same components.
  • Node 20.10+ (the node: import prefix and stable import.meta.url resolution matter for the server entry).
  • Vite 5.x or 6.x with "type": "module" in package.json so the server bundle and your runner agree on ESM.
  • A framework plugin: @vitejs/plugin-react 4.x or @vitejs/plugin-vue 5.x, plus the matching *-dom/server renderer.
  • A clear split between src/entry-client.ts (calls hydrateRoot/createSSRApp().mount) and src/entry-server.ts (exports an async render(url)).

These are not arbitrary version floors. Node 20.10 is the first release where import.meta.url and the node: prefix are stable enough that a server bundle emitted by Rollup resolves the same way under node --import tsx as it does after a plain node dist/server.js; on older runtimes the same bundle can resolve a dependency’s exports map differently between dev and prod, which surfaces as a phantom ERR_MODULE_NOT_FOUND that only appears in one environment. The "type": "module" requirement is equally load-bearing: if package.json omits it, Node treats the emitted .js server bundle as CommonJS, and any top-level await or ESM-only dependency in the server graph fails to parse before your render function is ever called. The reason the entry split must be clean — no shared module that imports both hydrateRoot and a Node-only API — is that Vite builds each entry as the root of its own graph; a module imported by both entries is compiled into both bundles, so a stray document reference in a “shared” helper will pass the client build and detonate on the server. Treat entry-server.ts as if window does not exist, because in its runtime it does not.

Core Mechanics: Two Graphs, One Source

Vite’s rendering model relies on a strict separation between client and server execution contexts. In development, Vite leans on esbuild for dependency pre-bundling; production builds delegate to Rollup 4.x for tree-shaking and code splitting. The architectural requirement for SSR/SSG is maintaining two isolated module graphs: one targeting the browser, one targeting Node. This prevents cross-environment leakage where browser-only globals (window, document) or DOM-dependent libraries enter the server path.

The word “graph” is precise. Vite models each entry as a directed graph of modules, where an edge is an import. The client graph and the server graph start from different roots (entry-client and entry-server) and are resolved with different rules, so a module can appear in one, both, or neither. Two things differ between the graphs even for a module that appears in both. First, resolution: the same bare specifier react-dom resolves to react-dom/client semantics in the browser graph and react-dom/server semantics in the Node graph, driven by the package’s exports conditions and the ssr.resolve.conditions you set. Second, transform: import.meta.env.SSR is statically replaced with true in the server graph and false in the client graph, so a if (import.meta.env.SSR) branch is dead-code-eliminated out of whichever bundle should not contain it. That single flag is the cleanest lever you have for keeping a browser-only side effect out of Node — it is evaluated at build time, not runtime, so the guarded code is physically absent from the wrong bundle rather than merely skipped.

Two isolated module graphs from one source One source tree compiles into a browser graph and a Node graph; ssr.noExternal inlines a package into the server bundle while ssr.external leaves it as a runtime import, and ssr.resolve.conditions node picks the server export branch. one sourceshared graph browser graphwindow/document ok Node graphno browser globals noExternal → inlinedESM-only / broken exports external → importNode resolves at runtime
Figure: one source, two graphs; noExternal inlines into the Node bundle, external defers to Node's resolver.

Establish the boundary with dual entry points and explicit dependency scoping. ssr.noExternal forces a package to be bundled (use it for ESM-only or exports-map-broken packages); ssr.external leaves it as a bare import for Node to resolve at runtime (the default for everything in node_modules):

// vite.config.ts — Vite 5.x / 6.x, Rollup 4.x, Node 20+
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';

export default defineConfig({
  plugins: [react()],
  build: {
    ssrManifest: true,
    rollupOptions: {
      input: {
        client: 'src/entry-client.tsx',
        server: 'src/entry-server.tsx',
      },
    },
  },
  ssr: {
    // Inline packages that ship broken or ESM-only exports maps:
    noExternal: ['some-esm-only-pkg', /^@scope\/ui-/],
    // Leave heavy server-safe deps as runtime imports (the default):
    external: ['pg', 'sharp'],
    // Pick the Node branch of every package's "exports" field:
    resolve: {
      conditions: ['node'],
    },
  },
});

The dev server transforms server modules on demand through vite.ssrLoadModule('/src/entry-server.tsx'), which applies SSR-specific ESM transforms (rewriting imports to __vite_ssr_import__), caches the result in memory, and invalidates on file change. There is no separate watch-and-rebuild step; the first request after an edit re-transforms only the touched module.

How ssrLoadModule works under the hood

ssrLoadModule is not a bundler. It is an interpreter loop over Vite’s module graph, and understanding its mechanism explains most of the surprising SSR behaviour. When you call it on entry-server, Vite resolves that module’s id, transforms its source (applying the framework plugin, TypeScript stripping, and the SSR-specific rewrites), and instead of leaving import statements in place, it replaces each one with a call to __vite_ssr_import__(id). That helper recursively loads the imported module through the same pipeline, so evaluation happens depth-first through your own source but stops at any module marked external. An external import is compiled to a real Node import, handed straight to Node’s resolver, and never transformed by Vite at all — which is exactly why an externalized package that assumes a bundler (import maps, ?raw suffixes, CSS imports) breaks, and why forcing it into noExternal fixes it: noExternal pulls the package back inside the interpreted graph where Vite’s transforms apply.

The caching cadence is per-module, keyed by resolved id, and stored on the running dev server. On the first request the whole graph is cold and every module is transformed and evaluated. On the next request, unchanged modules are served from the evaluated-module cache — Vite does not re-run their top-level code, it returns the same module namespace object. When you save a file, Vite’s file watcher invalidates that module’s cache entry and the entries of everything importing it transitively, so the next request re-evaluates precisely that subtree and reuses the rest. This is why an SSR edit feels instant even in a large app, and also why module-level state in a server module is a trap: because top-level code runs once per evaluation and the evaluation is cached, a let requestCount = 0 at module scope persists across requests and leaks state between users until the next invalidation. Keep per-request state inside the render function, never at module scope.

One consequence worth internalizing: ssrLoadModule returns fresh, transformed code that reflects your latest edit, but a package you have externalized is loaded once by Node and cached in Node’s own require/import cache for the process lifetime. Editing an externalized dependency’s source therefore does nothing until you restart the process, whereas editing your own source is picked up on the next request. When “my change isn’t showing up” only affects one file, check which side of the external/noExternal line it falls on before blaming HMR.

Configuration & CLI Reference

A minimal SSR server in middleware mode wires Vite’s transform pipeline directly into your HTTP framework. The full Express variant, including production static serving and error middleware, lives at Configuring Vite SSR with Express and Node.js:

Middleware-mode SSR request pipeline A request passes through Vite's middlewares, ssrLoadModule loads the server entry, render(url) returns HTML and state, and the server assembles the document with the client module script before responding. GET /routevite.middlewares ssrLoadModuleentry-server render(url)html + state assemble doc+ client script res
Figure: middleware mode threads Vite's transform pipeline directly into the request handler.
// server.ts — Vite 6.x, Node 20+, run with: node --import tsx server.ts
import express from 'express';
import { createServer as createViteServer } from 'vite';

async function createServer() {
  const app = express();
  const vite = await createViteServer({
    server: { middlewareMode: true },
    appType: 'custom',
  });

  app.use(vite.middlewares);

  app.use('*', async (req, res, next) => {
    const url = req.originalUrl;
    try {
      const { render } = await vite.ssrLoadModule('/src/entry-server.tsx');
      const { html, state } = await render(url);
      const page = `<!doctype html><div id="app">${html}</div>` +
        `<script>window.__STATE__=${JSON.stringify(state)}</script>` +
        `<script type="module" src="/src/entry-client.tsx"></script>`;
      res.status(200).set({ 'Content-Type': 'text/html' }).end(page);
    } catch (e) {
      vite.ssrFixStacktrace(e as Error);
      next(e);
    }
  });

  app.listen(3000, () => console.log('SSR dev server on http://localhost:3000'));
}

createServer();

A few details in that handler are load-bearing rather than incidental. appType: 'custom' tells Vite not to install its own HTML-serving fallback middleware, which would otherwise intercept the catch-all route and serve index.html before your renderer runs; with a custom app you own the response entirely. vite.ssrFixStacktrace(e) is not cosmetic: because ssrLoadModule evaluates transformed source, an unhandled error’s stack points at rewritten line numbers full of __vite_ssr_import__, and this call remaps the frames back to your original source using the transform’s sourcemap so the thrown line is actually findable. Dropping it is the single most common reason SSR errors are undebuggable. Finally, the catch-all app.use('*') must be registered after vite.middlewares, because the Vite middleware is what serves /src/entry-client.tsx and every transformed asset the browser requests during hydration; invert the order and the client script 404s while the server-rendered HTML looks perfect.

For production, the build is two passes: vite build emits the client and the ssr-manifest.json; vite build --ssr src/entry-server.tsx emits the server bundle. The manifest lets the server inject preload <link> tags for the exact chunks each route needs, eliminating client-side chunk discovery on first paint.

The reason it must be two passes and not one is that the two graphs have incompatible output targets. The client pass emits hashed, code-split chunks with <link rel="modulepreload"> relationships and a manifest mapping source modules to their output files; the server pass emits a single Node-loadable bundle with ssr.target defaulting to node, no hashing, and externals left as bare imports. The ssr-manifest.json is the bridge between them: it maps each source module id to the list of client chunk files it depends on, so at request time your server can look up the route’s server module, read which client chunks it will need, and emit exactly those preload links in the initial HTML. Without the manifest the browser discovers those chunks only after parsing and executing the entry, adding a full round trip to the critical path; with it, the chunks are already in flight before hydration begins. Run the two passes in order — client first — because the SSG and production server code both read the manifest the client pass produces, and a stale manifest silently preloads the wrong hashes after a client-only change.

Step-by-Step SSG Workflow

SSG reuses the same render export but runs it at build time across an enumerated route list, writing each result to disk so no Node process runs in production. The economic argument is straightforward: if a route’s data changes on a deploy cadence rather than per request, paying the render cost once at build time and serving a static file is strictly cheaper and more resilient than paying it on every request. The engineering catch is that “the same render export” now runs in a batch context with no incoming HTTP request, so any code that read from req — cookies, headers, the requesting user — has nothing to read, and any data fetch must be reachable from the build machine rather than from a live backend behind request auth. SSG is therefore only sound for routes whose output is a pure function of the URL plus build-time-available data.

Five-step build-time SSG loop Build client assets with the manifest, load the server entry once, render every enumerated route, write each to dist static index.html, then verify each file contains real markup. 1 · build client+ manifest 2 · load entryssrLoadModule 3 · render loopeach route 4 · write filesdist/static 5 · verifygrep h1
Figure: the same render export runs at build time — the output is static files, no runtime Node.
  1. Build client assets with await build({ build: { outDir: 'dist/static', ssrManifest: true } }) to produce hashed chunks and the manifest.
  2. Load the server entry via a one-off createServer({ appType: 'custom' }) and vite.ssrLoadModule.
  3. Render every route in a loop, serializing fetched data into an inline <script type="application/json"> for hydration.
  4. Write files to dist/static/<route>/index.html, creating parent directories first.
  5. Verify with npx serve dist/static and confirm each route’s HTML contains real markup (not an empty #app) via curl -s localhost:3000/docs | grep -c '<h1'.
// scripts/build-ssg.ts — Vite 6.x, Node 20+
import { build, createServer } from 'vite';
import fs from 'node:fs/promises';
import path from 'node:path';

const routes = ['/', '/about', '/docs', '/pricing'];

async function generateStatic() {
  await build({ build: { outDir: 'dist/static', ssrManifest: true } });
  const vite = await createServer({ appType: 'custom', server: { middlewareMode: true } });
  const { render } = await vite.ssrLoadModule('/src/entry-server.tsx');

  for (const route of routes) {
    const { html, state } = await render(route);
    const doc = `<!doctype html><div id="app">${html}</div>` +
      `<script type="application/json" id="__STATE__">${JSON.stringify(state)}</script>`;
    const file = path.join('dist/static', route === '/' ? 'index.html' : `${route}/index.html`);
    await fs.mkdir(path.dirname(file), { recursive: true });
    await fs.writeFile(file, doc);
    console.log(`pre-rendered ${route}`);
  }
  await vite.close();
}

generateStatic().catch((e) => { console.error(e); process.exit(1); });

Two properties of that loop are deliberate. It loads the server entry exactly once and reuses the evaluated module across every route, so the framework’s module-level setup cost is paid a single time rather than per route; and it renders sequentially. Sequential rendering is the safe default because ssrLoadModule shares one module graph and one module cache across the whole process, so if any of your components hold module-scoped mutable state, rendering two routes concurrently would let them interleave and corrupt each other’s output. Only parallelize once you have audited that every module in the server graph is stateless between renders.

For anything beyond a handful of routes the enumerated list becomes the bottleneck, so real SSG derives the route list from data rather than hard-coding it, and bounds concurrency explicitly. The pattern below reads the routes from the same source your app uses, renders with a small worker pool, and keeps the write step identical:

// scripts/build-ssg-dynamic.ts — Vite 6.x, Node 20+
import { build, createServer } from 'vite';
import fs from 'node:fs/promises';
import path from 'node:path';

const CONCURRENCY = 4; // stateless graph verified; raise cautiously

async function generateStatic() {
  await build({ build: { outDir: 'dist/static', ssrManifest: true } });
  const vite = await createServer({ appType: 'custom', server: { middlewareMode: true } });
  const { render, listRoutes } = await vite.ssrLoadModule('/src/entry-server.tsx');

  // listRoutes() returns the same route table the app router uses,
  // including data-derived paths like /blog/:slug expanded to concrete urls.
  const routes: string[] = await listRoutes();
  const queue = [...routes];

  async function worker() {
    let route: string | undefined;
    while ((route = queue.shift())) {
      const { html, state } = await render(route);
      const doc = `<!doctype html><div id="app">${html}</div>` +
        `<script type="application/json" id="__STATE__">${JSON.stringify(state)}</script>`;
      const file = path.join('dist/static', route === '/' ? 'index.html' : `${route}/index.html`);
      await fs.mkdir(path.dirname(file), { recursive: true });
      await fs.writeFile(file, doc);
      console.log(`pre-rendered ${route}`);
    }
  }

  await Promise.all(Array.from({ length: CONCURRENCY }, worker));
  await vite.close();
}

generateStatic().catch((e) => { console.error(e); process.exit(1); });

The verification step is not optional busywork. A component that swallows a data-fetch error and renders an empty shell will happily write a valid-looking but content-free index.html, and because SSG produces no runtime errors, nothing else will catch it — the page just ships blank. Asserting that each file contains a real <h1> element (via grep or a proper HTML assertion) turns a silent content regression into a failing build. Wire that assertion into the loop’s exit code, not just the console, so CI actually stops.

Debugging & Failure Modes

Four SSR failure modes by symptom and fix window is not defined means a browser global at import time — defer or guard with import.meta.env.SSR; ERR_MODULE_NOT_FOUND means a bad specifier — add extensions or noExternal; hydration mismatch means diverging render; and a plugin in the wrong environment needs applyToEnvironment. window is not defined browser global at import → defer / guard SSR ERR_MODULE_NOT_FOUND bad specifier → add extension or noExternal hydration mismatch warning server/client render diverged → its own guide plugin in wrong environment scope with applyToEnvironment / ssr branch
Figure: four symptoms, four fixes — the hydration row has its own dedicated guide.

window is not defined during render

The symptom is a ReferenceError thrown during the very first server render, with a stack that (once ssrFixStacktrace has remapped it) points at a top-level statement in one of your modules or a dependency. The root cause is always the same shape: some code touched a browser global — window, document, navigator, localStorage — at module-evaluation time rather than inside a lifecycle hook, and because ssrLoadModule evaluates every imported module’s top-level code, that access fires on Node the instant the module is imported, before any render logic runs. The distinction that matters is import time versus render time: a document.querySelector inside useEffect/onMounted never runs on the server, but the same call at the top of the module, or in a component body that executes during render, does. The fix is to move the access into an effect that only runs after hydration, or to guard it with if (import.meta.env.SSR) return / if (typeof window !== 'undefined') so the server path is physically skipped. When the offender is a dependency you cannot edit, either force it into ssr.noExternal and wrap it, or lazy-import it inside an effect. Confirm the fix by running VITE_DEBUG=ssr vite dev, which logs each module the SSR transform pipeline pulls in and lets you pinpoint which import dragged the global onto the server before you even reproduce the error.

ERR_MODULE_NOT_FOUND in the server graph

The symptom is Node’s ESM resolver aborting the render with a module-not-found error naming a specifier that clearly exists on disk or in node_modules. The root cause is that the specifier is externalized, so Vite handed it verbatim to Node, and Node’s resolver is stricter than a bundler’s: it will not guess a .js extension on a relative import, and it refuses any package whose exports map has no condition matching what Node asks for. Two distinct sub-cases: a relative import like ./util with no extension resolves under Vite but not under raw Node, so add the explicit ./util.js; and a bare package that only ships a browser or default export with no import/node condition cannot be loaded by Node at all, so add it to ssr.noExternal and let Vite inline and transform it instead. Before reaching for noExternal, inspect the package’s exports field — if it exposes an import or node condition and the error persists, the problem is your ssr.resolve.conditions, not the package. Confirm by loading the offending module in isolation with a one-line node --input-type=module -e "import('the-package')"; if that fails too, the package is genuinely Node-incompatible and noExternal is the only route.

Hydration mismatch warnings

The symptom is a console warning that server-rendered HTML did not match the client, often followed by a visible flash as the framework discards and re-renders a subtree. Server and client produced different markup — the usual causes are non-deterministic values (Date.now(), Math.random(), locale-dependent formatting), reading a browser-only value during the first render, or a data source that returned different bytes to the server fetch and the client refetch. This is its own diagnostic exercise, covered end to end in Fixing Hydration Mismatch Errors in Vite SSR; the one principle to carry into it is that the serialized state script exists precisely to eliminate the refetch class of mismatch, so if the client is refetching data the server already had, wire it to read window.__STATE__ instead.

Plugins running in the wrong environment

The symptom is subtler than a crash: a build succeeds but the server output contains a transform that only made sense for the browser (an injected HMR runtime, a CSS-to-JS shim, an asset URL rewrite), or a client asset is missing a transform the plugin was supposed to apply. The root cause is a plugin whose transform or resolveId hook ran against both graphs when it should have run against one. Scope it with applyToEnvironment in Vite 6, or branch on this.environment?.name === 'ssr' inside the hook so the server graph is left untouched; the hook precedence rules are detailed in Advanced Vite Plugin Configuration. Confirm the scoping worked by grepping the emitted server bundle for the artifact the plugin injects — it should be absent. When preview and production output diverge, cross-check against Optimizing Vite Dev Server and HMR, since HMR-injected modules never reach the SSG build and a plugin that only misbehaves under vite dev is almost always leaning on a dev-only injection that the production passes strip out.

Performance Impact & Measurement

Three SSR performance levers Setting ssr.resolve.conditions to node cuts server resolution time by roughly 60 percent; streaming HTML shaves 120 to 200 milliseconds off TTFB; and the SSR manifest removes redundant chunk discovery on first paint. conditions: ['node']~60% faster resolutionskips browser exports branch streaming render−120–200ms TTFBpipeable / web stream ssrManifestpreload exact chunksno first-paint discovery Three levers, each measured — not assumed
Figure: resolution, TTFB, and first-paint discovery — three independent, measurable wins.

Isolating the server module graph with ssr.resolve.conditions: ['node'] cuts server bundle resolution time roughly 60% by bypassing browser exports branches. Streaming HTML through a ReadableStream (React’s renderToPipeableStream, Vue’s renderToWebStream) shaves 120–200ms off TTFB on high-latency networks versus buffering a full string. Pre-rendering with build.ssrManifest: true removes redundant chunk discovery during the initial parse, measurably lowering Time to Interactive. Measure with clinic doctor -- node server.js under concurrent load to catch heap growth in ssrLoadModule’s module cache, and grep the client output for import.meta.env.SSR to confirm the flag was statically replaced.

Treat each lever as independent and measure it in isolation, because they trade against different parts of the request. The conditions win is pure server CPU: it shrinks the work the resolver does per module, so it shows up as lower render latency under load and is best measured with a flame graph rather than a single-request timer. Streaming trades a smaller time-to-first-byte for a slightly later time-to-complete, and it only pays off when the render has a slow part (a data fetch mid-tree) that can be flushed around; for a route that renders instantly from already-fetched state, streaming adds framing overhead for no gain, so measure TTFB and full-document time together and keep streaming only where the gap is real. The manifest preload win is a critical-path change visible only in a cold-cache waterfall, so measure it with the network cache disabled or it will look like it does nothing. The reliable failure signature to watch for is monotonic heap growth across thousands of renders: that is almost always module-scoped state accumulating in the SSR cache, and it will not appear in a single-request benchmark — only under sustained concurrency does it surface, which is why clinic doctor under load is the right tool rather than a stopwatch around one call.

When Not to Use SSR or SSG

SSR is not free, and reaching for it reflexively is its own failure mode. Every server render is CPU you now pay per request, plus a Node process you now have to run, scale, and keep from leaking module-scoped state — costs a static client bundle on a CDN simply does not have. If a route is behind authentication, has no SEO value, and shows the same shell to every user before fetching personalized data, server-rendering it buys nothing: crawlers never see it, the first paint is a loading state either way, and you have added a render tier to operate for no user-visible win. Ship those routes as a plain SPA and spend the SSR budget on the routes where a meaningful, crawlable first paint actually changes the outcome — marketing surfaces, documentation, product and content pages.

The choice between SSR and SSG is a function of how the data changes, not of framework fashion. If a route’s content is a pure function of the URL plus data available at build time, SSG is strictly better: no runtime tier, cacheable at the edge, immune to a backend outage. The moment the output depends on the request — the logged-in user, a cookie, a geo header, or data that must be fresh to the second — SSG cannot express it and you need SSR (or a hybrid where a static shell hydrates and then fetches the dynamic slice). The trap in between is a route with thousands of pages that change hourly: full SSG rebuilds the world on every change and SSR pays the render on every hit, so that is exactly the case where incremental or on-demand rendering, not either pure mode, is the right answer.

CI Integration

The whole point of the verification step earlier is to make SSR and SSG failures block a merge rather than surface in production, and that only happens if CI runs the same two-pass build and asserts on its output. Run the client build and the server build as ordered, non-parallel steps — client first, so the manifest exists before the server pass or the SSG script reads it — and treat any nonzero exit from either as a hard failure. For SSG, run the generation script and then assert on the emitted files: fail the job if any index.html is missing, is smaller than a sane floor, or lacks the route’s expected heading. A one-line gate like find dist/static -name index.html -size -200c catches empty shells, and piping each file through a real HTML assertion catches the subtler case where markup rendered but the data slot came back empty.

Pin the toolchain in CI to the versions in the compatibility matrix, because the failure this prevents is the nastiest kind: a server bundle that resolves a dependency’s exports map one way on a developer’s Node 22 and another way on CI’s Node 18, producing an ERR_MODULE_NOT_FOUND that reproduces on exactly one machine. Lock the Node minor, install from the lockfile, and run the SSR smoke test — boot the server, request one route, assert the response contains real markup and not an error page — as a required check. That smoke test is cheap and catches the entire class of “builds fine, renders 500” regressions that a bundle-only build step sails past.

Compatibility Matrix

SSR API surface by Vite major Vite 5 provides stable ssrLoadModule, ssr.external and noExternal and ssrManifest; Vite 6 adds the Environment API with per-environment plugin scoping; and Vite 6 with experimental Rolldown keeps the same config keys. Vite 5ssrLoadModule, external, manifest Vite 6Environment API scoping 6 + Rolldownexperimental, same keys
Figure: the config keys are stable from 5.x onward; the Environment API is the 6.x addition to watch.
Vite Rollup Node SSR API surface Notes
5.x 4.x 18.18+ / 20+ ssrLoadModule, ssr.external/noExternal, ssrManifest Stable; ssr.target defaults to node.
6.x 4.x 20.10+ Above + Environment API (this.environment) Per-environment plugin scoping via applyToEnvironment.
6.x + Rolldown (experimental) Rolldown 20.10+ Same config keys Drop-in via rolldown-vite; verify noExternal regex parity.

In-Depth Guides