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.
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.
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.
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 condition—browser, 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.* 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.
// 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.
| 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.
// 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
curl -s localhost:3000/ | grep -c '<div id="app">'returns1and the div contains real markup, not an empty shell.vite build --ssr src/server/entry-server.jsxwritesdist/server/entry-server.js; check its top-level imports match what you markedexternal.- Hitting an asset path like
/assets/index-*.jsreturns200fromexpress.static, confirming static serving sits ahead of the catch-all without being shadowed by it.
Gotchas & Edge Cases
- Middleware order. Registering a body parser or logger that consumes the request stream before
vite.middlewarescan 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. Mountvite.middlewaresfirst, 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. Useapp.use('*', …)for a true catch-all, or a named wildcard likeapp.get('/*splat', …)if you need the matched segment. This bites people migrating an Express 4 SSR server, where the exact sameapp.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.staticshadowing routes. Without{ index: false },express.statictreatsdist/client/index.htmlas 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 withcurl -s / | grepthat 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.
Related
- Vite SSR and SSG Integration — the dual-graph model and SSG pipeline this server plugs into.
- Fixing Hydration Mismatch Errors in Vite SSR — when the server HTML and client render disagree.
- Vite Configuration & Ecosystem — resolver conditions, build modes, and plugin ordering referenced above.