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.

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.

Prerequisites

  • 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)).

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.

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.

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:

// 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();

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.

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.

  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); });

Debugging & Failure Modes

window is not defined during render

A module touched a browser global at import time on the server. Trace the offending top-level import, then defer the access into an effect (useEffect/onMounted) or guard with import.meta.env.SSR. Run with VITE_DEBUG=ssr vite dev to surface which module the transform pipeline pulled in.

ERR_MODULE_NOT_FOUND in the server graph

Node’s ESM resolver rejected a bare specifier or a missing .js extension. Append explicit extensions to relative imports, or add the package to ssr.noExternal so Vite inlines it instead of handing it to Node. Confirm the package’s exports field exposes an import or node condition.

Hydration mismatch warnings

Server and client produced different markup. This is its own diagnostic exercise—non-deterministic data, Date/Math.random, or browser-only branches—covered end to end in Fixing Hydration Mismatch Errors in Vite SSR.

Plugins running in the wrong environment

A transform meant for the client mutated the server graph. Scope plugins with applyToEnvironment or branch on this.environment?.name === 'ssr' inside transform; the hook precedence rules are detailed in Advanced Vite Plugin Configuration. When preview and production output diverge, cross-check against Optimizing Vite Dev Server and HMR, since HMR-injected modules never reach the SSG build.

Performance Impact & Measurement

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.

Compatibility Matrix

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