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.
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.
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.
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.
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.
// 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.
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. |
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.
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 stream before
vite.middlewarescan break HMR injection. Mount Vite first, then your own middleware. app.get('*')on Express 5. Express 5’s path-to-regexp rejects bare'*'; useapp.use('*', …)or a named wildcard'/*splat'for the catch-all.express.staticshadowing routes. Without{ index: false }, static serving answers/with a staleindex.htmlinstead of the SSR handler.- Stale SSR bundle in memory. A perpetually running production process keeps the first imported
entry-server.js; redeploys need a restart, not just new files on disk.
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.