Vite SSR in Practice: Server Entry, Externalization, and Common Pitfalls
Vite's SSR mode reuses your client source for Node rendering, but it requires a server entry, careful dependency externalization, and guards against browser globals. Here is how to wire it up and what breaks.
22 Jul 2025, 17:14 UTC

Why Vite SSR needs a server entry, not just a flag
Vite's SSR support does not turn your existing client build into a server renderer automatically. You must provide a server entry, decide which dependencies Vite should bundle versus leave to Node's require, and ensure your components do not touch browser-only globals during the initial render. The useful takeaway: treat SSR as a second build target that shares source files, not as a mode you toggle.
How Vite handles SSR modules in development and production
In development, Vite runs a middleware server. When an HTTP request arrives, the server loads your server entry through Vite's module runner, executes the render function, and sends the resulting HTML string. In production, you run a separate build command to produce a Node bundle.
Vite's default behavior for SSR is to externalize dependencies. That means packages in node_modules are left as import or require statements in the output, assuming Node will resolve them at runtime. This keeps the server bundle small and avoids double-processing, but it fails when a package ships only browser builds or ESM syntax that your Node version cannot execute.
Worked example: Vue render function and Express middleware
Assume a Vue project. Create a server entry that exports a render function. This file runs only on the server.
// src/entry-server.js
import { createApp } from './app.js'
import { renderToString } from '@vue/server-renderer'
export async function render(url) {
const app = createApp()
const html = await renderToString(app)
return html
}
Next, wire a Node server. In development, use Vite's createServer in middleware mode.
// server.js
import express from 'express'
import { createServer } from 'vite'
const app = express()
let vite
if (process.env.NODE_ENV === 'development') {
vite = await createServer({
server: { middlewareMode: true },
appType: 'custom'
})
app.use(vite.middlewares)
} else {
// Serve the built client assets from their output directory
app.use(express.static('dist/client'))
}
app.get('*', async (req, res) => {
try {
let render
if (vite) {
// Load the entry through Vite so transforms and HMR apply
render = (await vite.ssrLoadModule('/src/entry-server.js')).render
} else {
render = (await import('./dist/server/entry-server.js')).render
}
const html = await render(req.url)
res.send(html)
} catch (err) {
res.status(500).send(err.message)
}
})
app.listen(5173)
Run node server.js from the project root. This assumes your package is set to ES modules and that port 5173 is available. No elevated permissions are required beyond normal file access.
Deciding what to externalize
If a dependency lacks a Node-compatible entry point, Vite's default externalization will crash at runtime. Use ssr.noExternal to force Vite to bundle it instead.
// vite.config.js
import { defineConfig } from 'vite'
export default defineConfig({
ssr: {
// Bundle these packages instead of leaving them for Node to resolve
noExternal: ['some-esm-only-package']
}
})
Conversely, if Vite tries to bundle a Node built-in or a server-only package, add it to ssr.external. The decision is diagnostic: if the server throws ERR_REQUIRE_ESM or a module-not-found error for a package that exists in node_modules, it likely needs noExternal. If the bundle size explodes or Node built-ins are polyfilled incorrectly, use external.
Building the server bundle for production
SSR requires a separate build step. Vite does not emit the server bundle during a normal client build.
# Build the client
vite build
# Build the server entry
vite build --ssr src/entry-server.js --outDir dist/server
The server bundle is a standard Node module. It is not optimized for edge runtimes unless you manually replace Node APIs. Test it locally with NODE_ENV=production node server.js.
Limits, crashes, and common mistakes
Guarding against browser globals
The most frequent crash is ReferenceError: window is not defined. This happens when component setup code, module scope, or third-party libraries access window, document, or localStorage before the component mounts. Guard such code:
if (typeof window !== 'undefined') {
// browser-only logic
}
You can also use Vite's define option to replace environment checks at build time, for example define: { 'process.env.BROWSER': false }, though runtime guards are usually more portable.
Client-only hooks and missing HTML
Effects and lifecycle hooks that assume a browser environment will not run during renderToString. If your HTML is missing content that only appears in an effect, the server output will be incomplete. Move that logic into the render function or a framework-agnostic data loader. In Vue, prefer onMounted for DOM access. In React, avoid useEffect for initial data that must appear in the HTML; use a data-fetching method that runs during render instead.
TypeScript paths and module resolution
Vite transpiles TypeScript during dev and build, but it does not type-check. A server entry with type errors will still run, but mismatched imports or missing type declarations can cause Cannot find module at runtime if path resolution differs. Run tsc --noEmit separately if you rely on strict type safety.
Externalizing Node-only packages
If your server entry imports a Node built-in like fs or a server-only package, ensure Vite does not try to bundle it. Node built-ins are usually handled automatically, but packages that assume a Node environment may need to be listed in ssr.external to prevent Vite from rewriting their imports.
Verifying the output
Start the server and request a page. View the page source in your browser or use curl http://localhost:5173/. The response should contain fully rendered HTML markup, not an empty root element. If you see only a shell div, the server entry is not executing or is throwing an error that is being swallowed. Check the terminal running node server.js for stack traces.
For production, confirm the server bundle exists and starts without import errors before deploying.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.