Configuring Vite SSR with Express and Node.js

Running Vite’s native SSR APIs behind an Express server fails in predictable ways—middleware mounted in the wrong order, browser globals reaching Node, ESM resolver gaps—and this guide gives the exact configuration that avoids each. It sits under Vite SSR and SSG Integration, which covers the dual-graph model this page assumes; here the focus is narrow: a working Express runtime in development and production with strict module boundaries, ESM alignment, and middleware ordering that preserves Vite’s transform pipeline.

The core reason this integration is awkward is that Vite and Express were designed to own the same request. A standard Vite dev server is an HTTP server: it listens on a port, serves your index.html, resolves and transforms modules on the fly, and injects the HMR client. Express also wants to be the HTTP server. You cannot run both listeners on one port, so the integration hinges on demoting Vite from a server to a Connect-style middleware stack that Express mounts. In that arrangement Vite stops listening, stops serving index.html automatically, and stops assuming it controls the response—but it keeps the transform pipeline, the module graph, and HMR. Everything that breaks in practice traces back to a boundary that this demotion left ambiguous: who resolves this specifier, who owns this response, which environment does this module think it runs in.

The second source of friction is that SSR runs your application code twice, in two different module systems, from two different builds. The same component that renders to a DOM node in the browser must render to an HTML string in Node, where there is no window, no document, and no bundler-injected process.env. Vite maintains two module graphs to keep these worlds apart, but the Express glue you write straddles both, and any leak—a top-level document.querySelector, a dependency whose package.json only exposes a browser build, a process.env reference Vite rewrote for the client but not the server—surfaces as one of a small, recognizable set of runtime errors. The rest of this guide is organized around producing a clean boundary in development, reproducing it exactly in production, and mapping every leak back to its cause.

Express middleware ordering for Vite SSR A request passes through Vite middleware first, then the catch-all route calls ssrLoadModule and render, with development and production paths diverging on how the server bundle loads. Express request pipeline Incoming GET req.originalUrl vite.middlewares mounted FIRST app.use('*') catch-all route render(url) HTML string Development middlewareMode: true vite.ssrLoadModule(entry-server) on-demand transform + HMR Production express.static(dist/client) import('./dist/server/entry-server.js') prebuilt bundle, no Vite at runtime
Figure: Vite middleware runs before the catch-all route; development loads the server entry through ssrLoadModule while production imports the prebuilt bundle.

Problem Scope

You have a Vite app that renders fine in the browser but needs server-rendered HTML, and you want Express—not a framework meta-runtime—to own routing. The work is making Vite’s transform pipeline and Express’s request lifecycle coexist without one swallowing the other’s responsibilities.

This is the right layer to work at when you already have Express as your API and session boundary and you want SSR to live in the same process, sharing auth middleware, request logging, and the same deployment unit. A meta-framework like Nuxt or the SvelteKit adapter would hide all of the wiring below, but it would also take ownership of routing, data loading, and the server entry point—which is exactly what you are trying to keep. The trade you are making is explicitness for control: you write the middleware order, the render function, and the two build passes by hand, and in return nothing is generated behind your back and every request path is one you can read in your own server.js. If you do not need that control, stop here and use an adapter; the sections below assume you do.

Express and Vite responsibilities coexisting Express owns routing, static serving and the response lifecycle, while Vite owns module transformation, HMR and the render pipeline; the integration problem is letting both operate without one shadowing the other. Express ownsrouting · static · response lifecycle Vite ownstransform · HMR · render pipeline
Figure: the integration goal is a clean division — neither side swallows the other's responsibilities.

Prerequisites & Reproducible Setup

Use a strict layout separating src/client from src/server so cross-environment imports are obvious and Rollup’s build target is unambiguous. Set "type": "module" in package.json to align with Vite’s ESM output and Node 20+ resolver behavior.

# Node 20.10+, Vite 6.x
npm install express vite @vitejs/plugin-react
npm install --save-dev tsx
mkdir -p src/client src/server

Pin Vite, Express, and the framework plugin to exact versions; a patch bump in Vite’s internal resolver or Express’s path-to-regexp layer can change behavior under SSR.

The reason the layout matters more here than in a client-only app is that under SSR a wrong import does not fail at build time—it fails at request time, deep inside ssrLoadModule, with a stack trace pointing at the transformed module rather than your source. When src/client and src/server are separate trees, an import that crosses the boundary is visible as a ../server/ in a client file or a ../client/ in a server file, and you catch it in review instead of in a 500. The tsx loader in the dev command is deliberate: it lets you run server.dev.js as ESM with on-the-fly TypeScript and JSX compilation for the server glue, while Vite—not tsx—handles transforming everything under src/ that ssrLoadModule pulls in. Keep those two responsibilities distinct; if you let tsx (or ts-node) transform your application modules, you lose Vite’s SSR-specific resolution and end up debugging two transform pipelines at once.

Strict client/server source layout Separating src/client from src/server makes cross-environment imports obvious and Rollup's build target unambiguous, with type module in package.json aligning ESM output across Vite and Node. src/client/browser entry + UI src/server/entry-server + render "type": "module"ESM across Vite + Node
Figure: the directory split makes an accidental cross-environment import visible in the path itself.

Step 1: Vite Configuration for SSR Targets

Set ssr.target: 'node' so browser polyfills never enter the server bundle, and list any ESM-only or broken-exports packages in ssr.noExternal to force Vite to inline them before Node’s resolver sees them.

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

export default defineConfig({
  plugins: [react()],
  ssr: {
    target: 'node',
    noExternal: ['lodash-es', 'some-esm-only-pkg'],
    resolve: { conditions: ['node'] },
  },
});

noExternal accepts package names or regex. An external package keeps its import statement intact for Node to resolve; an internal one is bundled by Rollup, which strips CJS wrappers and guarantees ESM compatibility.

The externalization decision is the single most consequential thing Vite does for an SSR build, so it is worth understanding the default. For the server graph Vite externalizes dependencies by default—it leaves import 'react-dom/server' untouched and lets Node resolve the package from node_modules at runtime. This keeps the build fast and small, because your dependency tree is not re-bundled on every change. The exceptions are the packages that Node cannot resolve or cannot run as-is: an author who ships only an ESM build with a bare-specifier import Node’s loader rejects, a package whose exports map has no node or import condition, or a CJS package that assumes a browser global. Listing those in noExternal moves them into the bundle, where Rollup applies the same interop, tree-shaking, and specifier rewriting it applies to your own code, and the runtime never sees the raw package. The cost is build time and the loss of Node’s own module cache for that dependency, so keep the list minimal and add to it only in response to a concrete ERR_MODULE_NOT_FOUND or a browser-global crash, not preemptively.

resolve.conditions: ['node'] is the other half of this. Modern packages expose different entry points through the exports field keyed by conditionbrowser, node, import, require, default. In a client build Vite resolves with the browser condition and gets the DOM-oriented build; in the server graph you want the node condition so a package like a database driver or a universal fetch shim resolves to its server implementation. Get this wrong and a package silently resolves to its browser entry inside Node, which is how you end up with a window-referencing module in a graph that has no window.

SSR target and dependency scoping ssr.target node keeps browser polyfills out of the server bundle, ssr.noExternal inlines ESM-only or broken-exports packages, and ssr.resolve.conditions node picks the server export branch. target: 'node'no browser polyfills noExternalinline ESM-only pkgs conditions: ['node']server export branch Three ssr.* keys that scope the server bundle
Figure: three ssr.* keys keep the server graph Node-only and correctly resolved.

Step 2: Express Middleware & Development Server

Run Vite in middlewareMode: true so there is no standalone dev server—HMR, asset resolution, and module transformation flow through Express. Mount vite.middlewares before any route so it can intercept asset requests and inject the HMR client.

appType: 'custom' is the flag people miss. With the default appType: 'spa', Vite installs its own HTML-serving and history-fallback middleware, which will answer / with a transformed index.html before your catch-all ever runs—defeating the whole point of taking over rendering. Setting appType: 'custom' strips those middlewares and leaves only the transform and asset-serving layers, so vite.middlewares handles /@vite/client, /@react-refresh, /src/... module requests, and static assets, and hands everything else down the Express chain to your route. The mental model is that vite.middlewares is a filter: it claims the requests that are unambiguously Vite’s and calls next() on the rest.

Middleware order: Vite before custom routes Vite middleware must be mounted before the catch-all route so it intercepts asset requests and injects the HMR client; a body parser or logger mounted first can consume the stream and break HMR injection. app.use(vite.middlewares)FIRST — HMR + assets your middlewareparsers, loggers app.use('*')SSR render Mount Vite first — a parser that consumes the stream first breaks HMR
Figure: order is behavioural — Vite's middleware must see the request before anything consumes the stream.
// server.dev.js — Vite 6.x, run with: node --import tsx server.dev.js
import express from 'express';
import { createServer } from 'vite';

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

  app.use(vite.middlewares); // BEFORE custom routes

  app.use('*', async (req, res, next) => {
    try {
      const { render } = await vite.ssrLoadModule('/src/server/entry-server.jsx');
      const html = await render(req.originalUrl);
      res.status(200).set({ 'Content-Type': 'text/html' }).end(html);
    } catch (e) {
      vite.ssrFixStacktrace(e);
      next(e);
    }
  });

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

startServer();

ssrLoadModule() caches transformed modules in memory and invalidates them on file change, so each edit re-transforms only the touched module rather than the whole graph.

Under the hood ssrLoadModule is not a Node import. Vite transforms the requested file for the SSR environment—rewriting its imports into __vite_ssr_import__ calls, applying your plugins’ transform hooks, and injecting the SSR-specific interop—then evaluates the result inside a controlled module runner that resolves each dependency back through the same function. The important consequence is that this graph is entirely separate from Node’s own require/import cache. When you save a file, Vite’s file watcher invalidates just that module’s entry and the entries that depend on it; the next ssrLoadModule call re-executes the invalidated slice and reuses the cached rest. That is why an SSR edit shows up on the next request with no server restart, and why the cost of a change scales with the affected subtree, not the whole app. It is also why vite.ssrFixStacktrace(e) matters: because the code that threw was transformed, the raw stack points at generated source, and ssrFixStacktrace remaps it back to your original file and line using the module’s source map.

The server entry that ssrLoadModule loads exports the render function the route calls. Keep it minimal—it is the one file that must run identically in the dev runner and the production bundle:

// src/server/entry-server.jsx — Vite 6.x, runs under ssrLoadModule (dev) and the prebuilt bundle (prod)
import { renderToString } from 'react-dom/server';
import { StaticRouter } from 'react-router-dom/server';
import App from '../client/App';

// url is req.originalUrl; return only the app markup, not the full document shell.
export async function render(url) {
  return renderToString(
    <StaticRouter location={url}>
      <App />
    </StaticRouter>,
  );
}

Return the app markup only, not the surrounding <html> document. In development your route reads index.html, passes it through vite.transformIndexHtml(url, template) so HMR and plugin-injected tags are present, and splices the render output into a placeholder; in production the same splice uses the built dist/client/index.html. Keeping the shell out of render is what lets one entry function serve both paths unchanged.

Diagnosis Workflow

When SSR breaks, the failure is almost always an environment mismatch or an ESM resolver gap. Work the signatures in order.

Four SSR error signatures and their fixes window is not defined means a client DOM API ran during render; ERR_MODULE_NOT_FOUND means an ESM resolver gap; process is not defined means process.env used client-side; and a mismatched markup warning means server and client diverged. window is not defined DOM API during render → defer / guard SSR ERR_MODULE_NOT_FOUND resolver gap → add .js / noExternal process is not defined use import.meta.env client-side mismatched markup warning render diverged → hydration guide
Figure: the same four signatures as the table below, matched to the one-line fix each needs.
Error signature Root cause Fix
TypeError: window is not defined Client-only DOM API ran synchronously during render. Move the access into useEffect/onMounted, or guard with import.meta.env.SSR.
ERR_MODULE_NOT_FOUND Node ESM resolver hit a bare specifier or missing .js extension. Append .js to relative imports; add the package to ssr.noExternal; confirm its exports exposes an import/node key.
ReferenceError: process is not defined Vite replaces process.env in client builds but not in Node SSR. Use import.meta.env client-side; access process.env directly in server entries.
Mismatched markup warning Server and client render diverged. See Fixing Hydration Mismatch Errors in Vite SSR.

The table compresses each signature to one line; here is the mechanism behind each, because the fix only sticks if you understand why it fired.

TypeError: window is not defined. The symptom is a crash during the very first render of a component or an imported module. The root cause is that module-scope or render-body code touched a browser-only global—window, document, localStorage, navigator—and Node has none of them. Anything at module top level runs the instant ssrLoadModule evaluates the file, before your component even mounts, so a top-level const width = window.innerWidth is guaranteed to throw. The fix is to move the access into an effect that only runs on the client (useEffect, onMounted), or to guard it with if (!import.meta.env.SSR) so the branch is dead code on the server. Confirm the fix by rendering the route with curl and checking that the 500 is gone and the markup contains the component’s static output; a component that needed the browser value will now render its server fallback, which is correct.

ERR_MODULE_NOT_FOUND. The symptom is a resolution failure naming a specifier, usually raised by Node’s loader rather than by Vite. There are two common roots. The first is a relative import missing its extension—Node’s strict ESM resolver does not guess .js, so import './foo' fails where a bundler would have resolved it; append the extension or let Vite bundle the file by keeping it inside your source graph. The second is a dependency Vite externalized that Node then cannot resolve, typically because the package’s exports map has no node or import condition. Add that package to ssr.noExternal so Rollup inlines it, or verify the package genuinely ships an importable entry. Confirm by re-running the request; if a new specifier fails next, resolve it the same way—these tend to come in short chains.

ReferenceError: process is not defined. This one is counterintuitive because it fires in Node, where process obviously exists. The cause is a module written for the client that reads process.env.SOMETHING, relying on Vite to statically replace it at build time. Vite performs that replacement only for the client build; in the SSR graph process is the real Node global, so a bare process reference in a module that Vite did not rewrite—often a dependency—can still surface if it was compiled expecting a browser shim. Use import.meta.env for anything meant to be inlined on both sides, and read process.env directly only in code you know runs in Node. Confirm by grepping the failing module for process.env and checking whether it belongs on the client at all.

Mismatched markup warning. The symptom is not a crash but a console warning during hydration, and often a flash where the client re-renders over the server HTML. The root cause is that the server and client produced different trees—commonly from rendering a timestamp, a random value, or a window-dependent branch on one side but not the other. The fix is environment-specific and covered in depth in Fixing Hydration Mismatch Errors in Vite SSR; the confirmation is a clean hydration with no warning and no visible re-paint on first load.

Run VITE_DEBUG=ssr node --import tsx server.dev.js to trace which module the transform pipeline pulled in, and NODE_OPTIONS="--enable-source-maps" to keep original stack traces through the SSR transform.

Solution: Production Build & Static Serving

Production is a two-pass build: vite build for the client, vite build --ssr src/server/entry-server.jsx for the server. Serve dist/client with express.static() and dynamically import() the compiled SSR bundle so there is no Vite dependency at runtime.

Two-pass production build and serving vite build emits the client bundle and vite build --ssr emits the server bundle; production serves dist/client with express.static and dynamically imports the compiled server bundle, so Vite is not a runtime dependency. vite build → dist/client vite build --ssr → dist/server express.static(dist/client) import('./dist/server/…') no Viteat runtime
Figure: two build passes feed a runtime that imports the prebuilt server bundle — Vite is dev-only.
// server.prod.js — Vite 6.x, Node 20+, "type":"module"
import express from 'express';
import { fileURLToPath } from 'node:url';
import { dirname, resolve } from 'node:path';

const __dirname = dirname(fileURLToPath(import.meta.url));
const app = express();

app.use(express.static(resolve(__dirname, 'dist/client'), { index: false }));

app.use('*', async (req, res) => {
  try {
    const { render } = await import('./dist/server/entry-server.js');
    const html = await render(req.originalUrl);
    res.status(200).set({ 'Content-Type': 'text/html' }).end(html);
  } catch (e) {
    console.error(e);
    res.status(500).end('Internal Server Error');
  }
});

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

The --ssr flag emits ESM or CJS depending on ssr.target and build.rollupOptions.output.format. Node caches imported modules by default, so a long-running process must restart on deploy (or invalidate its cache) to pick up new route handlers.

The reason production is a separate build rather than “the dev server with NODE_ENV=production” is that ssrLoadModule and the on-demand transform pipeline are development machinery. Shipping them means shipping Vite, esbuild, and your whole dependency tree in source form, and paying transform cost on the first request to every route. The prebuilt server bundle is Rollup output: tree-shaken, with your noExternal packages inlined and everything else left as plain import statements Node resolves from node_modules. It has no watcher, no HMR, and no source-map remapping runner, which is exactly what you want in a process that should start fast and stay put. The two passes are genuinely two Rollup builds with different input and different externalization rules—the client pass emits hashed asset files and the manifest, the server pass emits a single entry that imports them by reference.

{ index: false } on express.static is load-bearing and easy to omit. The client build writes dist/client/index.html; without index: false, express.static treats that file as the directory index and answers GET / with the raw, un-rendered shell before your catch-all can produce SSR markup. Disabling the index makes express.static serve only real asset files—the hashed JS and CSS under /assets/—and fall through to the SSR route for document requests. This is the production analogue of mounting Vite first in development: the static layer must claim assets and nothing else.

Verification

Three Express SSR verification checks Confirm the root response contains one app div with real markup, confirm the SSR build wrote dist/server/entry-server.js with the expected externals, and confirm an asset path returns 200 from express.static ahead of the catch-all. curl / | grep app div 1 + real markup build --ssr writes entry-server.js externals match GET /assets/index-*.js 200 from static
Figure: render output, server bundle, and static serving — three checks that the two halves are wired correctly.
  • curl -s localhost:3000/ | grep -c '<div id="app">' returns 1 and the div contains real markup, not an empty shell.
  • vite build --ssr src/server/entry-server.jsx writes dist/server/entry-server.js; check its top-level imports match what you marked external.
  • Hitting an asset path like /assets/index-*.js returns 200 from express.static, confirming static serving sits ahead of the catch-all without being shadowed by it.

Gotchas & Edge Cases

Four Express SSR edge cases A parser mounted before Vite breaks HMR; Express 5 rejects a bare star route; express.static without index false shadows the SSR handler; and a long-running process keeps a stale server bundle until restart. middleware order a parser/logger before Vite consumes the stream → mount Vite first. Express 5 bare '*' path-to-regexp rejects it — use app.use('*', …) or '/*splat'. static shadows routes without { index: false }, static answers / with stale index.html. stale SSR bundle a running process keeps the first import — redeploys need a restart, not new files.
Figure: two ordering/routing traps on top, two lifecycle traps below.
  • Middleware order. Registering a body parser or logger that consumes the request stream before vite.middlewares can break HMR injection and module transforms, because Vite may need the raw request and expects to run first. The rule is not “parsers are bad”—it is that anything reading or rewriting the stream must sit after Vite in the chain. Mount vite.middlewares first, then your parsers and loggers, then the catch-all. If HMR silently stops working after you add middleware, this ordering is the first thing to check.
  • app.get('*') on Express 5. Express 5 upgraded its path-to-regexp dependency, which no longer accepts a bare '*' as a route path and throws at registration time. Use app.use('*', …) for a true catch-all, or a named wildcard like app.get('/*splat', …) if you need the matched segment. This bites people migrating an Express 4 SSR server, where the exact same app.get('*') had worked for years; the fix is mechanical once you recognize the startup error as a routing-syntax rejection rather than a Vite problem.
  • express.static shadowing routes. Without { index: false }, express.static treats dist/client/index.html as the directory index and answers / with the un-rendered shell, so every page loads as a blank client-only app and SSR appears to do nothing. The symptom is deceptive because assets load fine and the app hydrates—it just never had server markup. Set { index: false } and confirm with curl -s / | grep that the root response contains rendered content.
  • Stale SSR bundle in memory. A perpetually running production process holds the first import('./dist/server/entry-server.js') in Node’s module cache for the life of the process. New files on disk change nothing until the process restarts, so a deploy that swaps the bundle without restarting keeps serving old route handlers. Treat a restart (or a fresh process behind your supervisor) as part of the deploy, not an afterthought; blue-green or rolling restarts avoid the window where old and new bundles coexist.