Configuring Vite SSR with Express and Node.js
This guide details the exact configuration required to run Vite’s native SSR APIs alongside an Express runtime. Understanding how Vite decouples client hydration from server execution is critical when integrating into the broader Vite Configuration & Ecosystem for scalable frontend delivery. The architecture relies on strict module boundary enforcement, explicit ESM alignment, and middleware ordering that preserves Vite’s transformation pipeline.
Prerequisites & Reproducible Project Structure
Establish a strict directory layout separating src/client and src/server. This isolation prevents accidental cross-environment imports and clarifies the build target for Rollup. Ensure your root package.json contains "type": "module" to align with Vite’s native ESM output and Node.js 18+ resolver behavior.
Install exact dependencies and scaffold directories:
npm install express vite @vitejs/plugin-react
mkdir -p src/client src/server
Pin dependency versions in package.json to prevent patch-level breaking changes in Vite’s internal resolver or Express’s routing layer.
Step 1: Vite Configuration for SSR Targets
Configure vite.config.js with an explicit ssr block. Set ssr.target: 'node' to prevent browser polyfills from leaking into the server bundle. Use ssr.noExternal to force Vite to inline packages that lack proper exports maps, preventing Node resolver failures during vite.ssrLoadModule().
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']
}
});
The noExternal array accepts package names or regex patterns. When a package is marked external, Vite leaves the import statement intact, relying on Node’s native resolution. When marked internal, Vite’s Rollup pipeline bundles it, stripping CJS wrappers and ensuring ESM compatibility.
Step 2: Express Middleware & Development Server
Initialize Vite in middlewareMode: true to bypass the standalone dev server. Pipe Vite’s HMR, asset resolution, and module transformation directly through Express. For advanced hydration patterns and streaming strategies, consult the Vite SSR and SSG Integration documentation.
import express from 'express';
import { createServer } from 'vite';
const app = express();
async function startServer() {
const vite = await createServer({
server: { middlewareMode: true },
appType: 'custom'
});
// Mount Vite middleware BEFORE custom routes
app.use(vite.middlewares);
app.get('*', async (req, res) => {
try {
const { render } = await vite.ssrLoadModule('/src/server/entry-server.js');
const html = await render(req.originalUrl);
res.status(200).set({ 'Content-Type': 'text/html' }).end(html);
} catch (e) {
vite.ssrFixStacktrace(e);
res.status(500).end(e.message);
}
});
app.listen(3000, () => console.log('Dev server running on http://localhost:3000'));
}
startServer();
vite.middlewares must be registered before route definitions to intercept static asset requests, inject HMR client scripts, and transform ESM modules on-the-fly. ssrLoadModule() caches transformed modules in memory and automatically invalidates them on file changes.
Exact Error Resolution & Root-Cause Analysis
SSR failures typically stem from environment mismatches or Node ESM resolver gaps. Below are exact error signatures, root causes, and step-by-step remediation paths.
| Error Signature | Root Cause | Step-by-Step Fix |
|---|---|---|
TypeError: window is not defined |
Client-only DOM APIs executed synchronously during server-side rendering. | 1. Identify the offending top-level import. 2. Wrap DOM calls in if (typeof window !== 'undefined').3. Use import.meta.env.SSR for conditional module loading. |
ERR_MODULE_NOT_FOUND: Cannot find module '...' |
Node ESM resolver fails on bare specifiers or missing .js extensions in relative imports. |
1. Append .js to all relative imports.2. Add the package to ssr.noExternal in vite.config.js.3. Verify package.json exports uses conditional import keys. |
ReferenceError: process is not defined |
Vite replaces process.env in client builds but leaves it undefined in Node SSR context. |
1. Use import.meta.env for client-side checks.2. Inject process.env via define: { 'process.env': JSON.stringify(process.env) } in Vite config.3. Access Node env directly in server entry files. |
When debugging ssrLoadModule failures, enable server.watch logging in vite.config.js to trace file dependency graphs. Use NODE_OPTIONS="--enable-source-maps" to preserve original stack traces during runtime evaluation.
Production Build & Static Asset Serving
Execute a dual-build pipeline: vite build for the client, vite build --ssr for the server. Configure Express express.static() to serve dist/client assets. Dynamically import() the compiled SSR bundle to avoid memory leaks in long-running Node processes.
import express from 'express';
import { fileURLToPath } from 'url';
import { dirname, resolve } from 'path';
const __dirname = dirname(fileURLToPath(import.meta.url));
const app = express();
// Serve static client assets
app.use(express.static(resolve(__dirname, 'dist/client')));
app.get('*', async (req, res) => {
try {
// Dynamic import prevents module caching memory leaks
const { render } = await import('./dist/server/entry-server.js');
const html = await render(req.url);
res.status(200).set({ 'Content-Type': 'text/html' }).end(html);
} catch (e) {
res.status(500).end('Internal Server Error');
}
});
app.listen(3000, () => console.log('Production server running on http://localhost:3000'));
The --ssr flag outputs a CommonJS or ESM bundle depending on ssr.target and build.rollupOptions.output.format. In production, Node’s native import() caches modules by default. If your server runs indefinitely, implement a module cache invalidation strategy or restart the process on deployment to prevent stale route handlers.
Frequently Asked Questions
How do I resolve ESM/CJS interoperability issues in Vite SSR?
Set ssr.target: 'node' and use ssr.noExternal for packages lacking proper ESM exports. Ensure your Express entry uses .mjs or "type": "module" to align with Vite’s output format. When consuming legacy CJS packages, Vite automatically wraps them in ESM-compatible proxies during the SSR build phase.
Why does HMR break when using custom Express middleware?
Vite’s HMR relies on WebSocket connections and specific middleware ordering. Mount vite.middlewares before custom routes, and avoid intercepting req.url or res.writeHead before Vite processes the request. If you must modify headers, use app.use((req, res, next) => { ...; next(); }) before the Vite mount point to preserve the transformation pipeline.