Vite 24 ⚡ SSR Fundamentals — Vite’s low-level server-side rendering APIs
Vite’s SSR support is not a framework. It is a set of low-level primitives that a framework or a custom server uses to render an application on the server and hydrate it in the browser. The documentation states this explicitly: the SSR guide is “a low-level API meant for library and framework authors,” and application developers are directed to the higher-level tools in the Awesome Vite SSR section first . The primitives are four: the middleware mode that lets Vite run inside a Node.js server, the transformIndexHtml method that applies the HTML transforms, the ssrLoadModule method that loads and transforms a module for the server without bundling, and the import.meta.env.SSR flag that branches the code between the two environments.
The architecture is a split entry. The entry-client.js file mounts the app to a DOM element in the browser. The entry-server.js file exports a render function that calls the framework’s SSR API to produce the HTML string. The index.html contains a placeholder—<!--ssr-outlet--> or any string that can be replaced—where the server-rendered markup is injected . The server reads the HTML, applies the transforms, loads the server entry, calls the render function, and replaces the placeholder with the result. The same main.js module is imported by both entries, so the application code is universal.
Key point: Vite’s SSR support is a middleware-mode server plus three methods: transformIndexHtml, ssrLoadModule, and the import.meta.env.SSR flag. It does not provide a router, a data-fetching layer, or a production server. Those are the framework’s responsibility. The low-level API is the foundation that the frameworks build on.
Why the low-level API is split this way
The middleware mode problem. An SSR application needs a server that handles the route, loads the module, renders the HTML, and returns the response. Vite’s dev server does not do this by default; it serves a static index.html and the client modules. The middleware mode changes that. The server.middlewareMode: true option and the appType: 'custom' option tell Vite to stop serving HTML and to expose its Connect instance as vite.middlewares. The parent server—Express, Fastify, or a bare Node.js http server—uses the middleware for HMR, static files, and module transforms, and handles the SSR route itself . The separation is the point: the parent server controls the routing and the response, and Vite handles the transformation.
The ssrLoadModule problem. In a traditional SSR setup, the server entry is bundled for Node.js before the server starts. The bundle is fast to load but slow to rebuild, and the rebuild is disconnected from the dev server’s HMR. Vite’s ssrLoadModule avoids the bundle. It loads the module through Vite’s transform pipeline, applies the TypeScript and JSX transforms on demand, and provides efficient invalidation similar to HMR . The server entry is always up to date, and the developer does not wait for a server bundle.
The transformIndexHtml problem. The index.html is not a static file in an SSR app. It contains the HMR client script, the plugin-injected tags, and the placeholder for the server-rendered markup. The transformIndexHtml method applies the same transforms that the dev server applies to the HTML, including the HMR injection and the plugin transforms . Without it, the HTML would be missing the scripts that make the client entry work.
The trade-off. The low-level API is verbose. A minimal SSR server is approximately 60 lines of code: the Express setup, the middleware mode, the route handler, the transformIndexHtml call, the ssrLoadModule call, the render call, and the error handling. The verbosity is the cost of the control. A framework wraps this in a single command, but the framework also makes the decisions about routing, data fetching, and production deployment. The low-level API makes no decisions; it provides the primitives.
a. The source structure and the conditional logic
The source structure is three files and an HTML template. The index.html references the client entry and contains the placeholder. The src/main.js exports the universal application code. The src/entry-client.js mounts the app. The src/entry-server.js exports the render function.
// index.html
// The placeholder is where the server-rendered markup is injected.
// The client entry is referenced here; the server entry is not.
<!DOCTYPE html>
<html>
<body>
<div id="app"><!--ssr-outlet--></div>
<script type="module" src="/src/entry-client.js"></script>
</body>
</html>
// src/entry-server.js
// The render function is called by the server for each request.
// It receives the URL and returns the HTML string.
import { renderToString } from 'framework/server';
export async function render(url) {
// The framework's SSR API renders the app to a string.
return renderToString(<App url={url} />);
}
The import.meta.env.SSR flag is the conditional branch. It is statically replaced during the build, so the unused branch is removed by tree-shaking. A module that reads window only runs in the client branch, and a module that reads a database only runs in the server branch .
// src/main.js
// The SSR flag is true on the server and false in the browser.
// The unused branch is removed at build time.
if (import.meta.env.SSR) {
// Server-only logic.
// This branch is not included in the client bundle.
const data = await db.query();
} else {
// Client-only logic.
// This branch is not included in the server bundle.
window.addEventListener('resize', handleResize);
}
The entry-client.js uses the framework’s hydration function to attach the event handlers to the server-rendered markup. The entry-server.js uses the framework’s render function to produce the markup. The two entries import the same main.js, which is the universal code.
b. The middleware mode server
The server is an Express application that uses Vite as a middleware. The createServer function from Vite is called with middlewareMode: true and appType: 'custom'. The vite.middlewares instance is mounted on the Express app. The catch-all route reads the index.html, applies the transforms, loads the server entry, renders the app, and returns the HTML.
// server.js
import fs from 'node:fs';
import path from 'node:path';
import express from 'express';
import { createServer as createViteServer } from 'vite';
async function createServer() {
const app = express();
// The middleware mode disables Vite's own HTML serving.
// The appType 'custom' disables the default HTML fallback.
// The parent server controls the routing and the response.
const vite = await createViteServer({
server: { middlewareMode: true },
appType: 'custom',
});
// The Vite middleware handles HMR, static files, and transforms.
app.use(vite.middlewares);
app.use('*all', async (req, res, next) => {
const url = req.originalUrl;
try {
// Read the HTML template.
let template = fs.readFileSync(
path.resolve(import.meta.dirname, 'index.html'),
'utf-8'
);
// Apply the Vite HTML transforms.
// This injects the HMR client and the plugin transforms.
template = await vite.transformIndexHtml(url, template);
// Load the server entry through Vite's transform pipeline.
// No bundling is required; the module is transformed on demand.
const { render } = await vite.ssrLoadModule('/src/entry-server.js');
// Call the render function and replace the placeholder.
const appHtml = await render(url);
const html = template.replace('<!--ssr-outlet-->', appHtml);
res.status(200).set({ 'Content-Type': 'text/html' }).end(html);
} catch (e) {
// Fix the stack trace to point to the source file.
vite.ssrFixStacktrace(e);
res.status(500).end(e.message);
}
});
app.listen(5173);
}
createServer();
The ssrFixStacktrace call is the error-handling primitive. It rewrites the stack trace from the transformed code back to the source file, so the error message points to the line the developer wrote . Without it, the stack trace points to the transformed module, which is unreadable.
The transformIndexHtml and ssrLoadModule methods are the core of the server. The first applies the HTML transforms; the second loads the server entry without a bundle. The render function is the framework’s responsibility. The server does not know what the framework does; it only knows that the function returns an HTML string.
Complete Example Session
// ============================================
// PART 1: THE HTML TEMPLATE
// ============================================
// index.html
// The placeholder is the injection point.
// The client entry is referenced here.
<!DOCTYPE html>
<html>
<body>
<div id="app"><!--ssr-outlet--></div>
<script type="module" src="/src/entry-client.js"></script>
</body>
</html>
// ============================================
// PART 2: THE UNIVERSAL ENTRY
// ============================================
// src/main.js
// The universal code is imported by both entries.
// The SSR flag branches the server-only and client-only logic.
export function createApp() {
// The framework's app creation function.
}
// ============================================
// PART 3: THE SERVER ENTRY
// ============================================
// src/entry-server.js
import { renderToString } from 'framework/server';
import { createApp } from './main.js';
// The render function is called by the server for each request.
// It returns the HTML string that replaces the placeholder.
export async function render(url) {
const app = createApp();
return renderToString(app, url);
}
// ============================================
// PART 4: THE CLIENT ENTRY
// ============================================
// src/entry-client.js
import { hydrateRoot } from 'framework/client';
import { createApp } from './main.js';
// The hydrate function attaches the event handlers
// to the server-rendered markup.
hydrateRoot(document.getElementById('app'), createApp());
// ============================================
// PART 5: THE MIDDLEWARE MODE SERVER
// ============================================
// server.js
import fs from 'node:fs';
import path from 'node:path';
import express from 'express';
import { createServer as createViteServer } from 'vite';
async function createServer() {
const app = express();
const vite = await createViteServer({
server: { middlewareMode: true },
appType: 'custom',
});
app.use(vite.middlewares);
app.use('*all', async (req, res) => {
const url = req.originalUrl;
try {
let template = fs.readFileSync(
path.resolve(import.meta.dirname, 'index.html'),
'utf-8'
);
// Apply the Vite HTML transforms.
template = await vite.transformIndexHtml(url, template);
// Load the server entry without bundling.
const { render } = await vite.ssrLoadModule('/src/entry-server.js');
// Render the app and replace the placeholder.
const appHtml = await render(url);
const html = template.replace('<!--ssr-outlet-->', appHtml);
res.status(200).set({ 'Content-Type': 'text/html' }).end(html);
} catch (e) {
vite.ssrFixStacktrace(e);
res.status(500).end(e.message);
}
});
app.listen(5173);
}
createServer();
// ============================================
// PART 6: THE SSR CONDITIONAL FLAG
// ============================================
// The import.meta.env.SSR flag is statically replaced.
// It is true on the server and false in the browser.
if (import.meta.env.SSR) {
// Server-only logic.
} else {
// Client-only logic.
}
// ============================================
// PART 7: THE SSR-ONLY MODULE RELOAD
// ============================================
// A module that is only imported by the server entry
// does not trigger a browser reload by default.
// The ssrReload plugin sends a full reload when the module changes.
// vite.config.js
import { defineConfig } from 'vite';
export default defineConfig({
plugins: [
{
name: 'ssr-reload',
enforce: 'post',
hotUpdate: {
order: 'post',
handler({ modules, server, timestamp }) {
if (this.environment.name !== 'ssr') return;
// Check for SSR-only modules and send a full reload.
server.environments.client.hot.send({ type: 'full-reload' });
return [];
},
},
},
],
});
// ============================================
// PART 8: THE PRODUCTION BUILD
// ============================================
// The production build has two steps:
// 1. Build the client bundle.
// 2. Build the server bundle.
// vite build
// vite build --ssr src/entry-server.js
// The client build produces the browser assets.
// The server build produces the Node.js server entry.
// The production server uses the built files, not the middleware mode.
// ============================================
// PART 9: THE SSR EXTERNALIZATION
// ============================================
// By default, Vite externalizes node_modules in SSR builds.
// A package that ships uncompiled CSS or ESM-only code
// must be bundled with noExternal.
// vite.config.js
export default defineConfig({
ssr: {
external: ['express'],
noExternal: ['my-ui-library'],
},
});
// The external packages are resolved at runtime.
// The noExternal packages are bundled into the server output.
// ============================================
// PART 10: THE VERIFICATION CHECKLIST
// ============================================
// [ ] The index.html has the placeholder for the server markup.
// [ ] The client entry is referenced in the index.html.
// [ ] The server entry exports the render function.
// [ ] The server uses middlewareMode: true and appType: 'custom'.
// [ ] The transformIndexHtml method is called before the render.
// [ ] The ssrLoadModule method loads the server entry.
// [ ] The ssrFixStacktrace method handles the errors.
// [ ] The import.meta.env.SSR flag branches the logic.
// [ ] The production build runs both the client and server builds.
The ten parts covered the HTML template, the universal entry, the server and client entries, the middleware server, the SSR flag, the SSR-only reload, the production build, the externalization, and the verification. The through-line is that the low-level API is four primitives, and the framework provides the rest.
Quick Reference
The SSR primitives
| Primitive | Purpose | Where |
|---|---|---|
server.middlewareMode: true | Run Vite as middleware | createViteServer options |
appType: 'custom' | Disable Vite’s HTML serving | createViteServer options |
vite.transformIndexHtml(url, html) | Apply HTML transforms | The catch-all route |
vite.ssrLoadModule(path) | Load a module for the server | The catch-all route |
vite.ssrFixStacktrace(error) | Rewrite the error stack trace | The catch block |
import.meta.env.SSR | Branch server/client logic | Any module |
The source structure
| File | Purpose |
|---|---|
index.html | The template with the placeholder |
src/main.js | The universal application code |
src/entry-client.js | The browser mount |
src/entry-server.js | The server render function |
server.js | The Express server with Vite middleware |
The SSR options
| Option | Purpose |
|---|---|
ssr.external | Packages to externalize (not bundle) |
ssr.noExternal | Packages to bundle (ESM-only, CSS) |
ssr.target | The target environment ('node' or 'webworker') |
Best Practices
✅ Do This:
// Use middleware mode for the dev server
const vite = await createViteServer({ server: { middlewareMode: true }, appType: 'custom' }); // ✅
// Apply the HTML transforms before the render
template = await vite.transformIndexHtml(url, template); // ✅
// Load the server entry without bundling
const { render } = await vite.ssrLoadModule('/src/entry-server.js'); // ✅
// Fix the stack trace in the catch block
vite.ssrFixStacktrace(e); // ✅
❌ Don’t Do This:
// Use the dev server without middleware mode for SSR
const vite = await createViteServer(); // no SSR control // ❌
// Skip the HTML transforms
const html = template.replace('<!--ssr-outlet-->', appHtml); // no HMR // ❌
// Bundle the server entry before the server starts
// The low-level API loads it on demand. // ❌
// Ignore the externalization
// A package with CSS will break in the server bundle. // ❌
Common Pitfalls
| Pitfall | Why It Happens | Fix |
|---|---|---|
| HMR does not work in SSR | The HTML transforms were not applied | Call transformIndexHtml |
| The server entry is stale | The module was bundled, not loaded | Use ssrLoadModule |
| The stack trace points to transformed code | ssrFixStacktrace was not called | Call it in the catch block |
| The package breaks in the server bundle | The package ships CSS or ESM-only code | Add it to ssr.noExternal |
| The client entry is missing | The index.html does not reference it | Add the <script> tag |
Real-World Examples
1. Middleware mode
const vite = await createViteServer({ server: { middlewareMode: true }, appType: 'custom' });
2. HTML transforms
template = await vite.transformIndexHtml(url, template);
3. Server module load
const { render } = await vite.ssrLoadModule('/src/entry-server.js');
4. Stack trace fix
vite.ssrFixStacktrace(e);
5. SSR flag
if (import.meta.env.SSR) { /* server logic */ }
6. Placeholder
<div id="app"><!--ssr-outlet--></div>
7. Client entry
<script type="module" src="/src/entry-client.js"></script>
8. Server entry
export async function render(url) { return renderToString(app, url); }
9. Externalization
ssr: { external: ['express'], noExternal: ['my-ui-library'] }
10. Production build
vite build && vite build --ssr src/entry-server.js
Visual
The SSR Request Flow
┌──────────────────────────────────────────────────────────────┐
│ FROM REQUEST TO RESPONSE │
│ │
│ Browser requests /users/123 │
│ │ │
│ ▼ │
│ ┌─────────────────────┐ │
│ │ Express server │ │
│ │ (middleware mode) │ │
│ └──────────┬──────────┘ │
│ │ │
│ ▼ │
│ ┌─────────────────────┐ │
│ │ Read index.html │ │
│ │ Apply transforms │ ← transformIndexHtml │
│ └──────────┬──────────┘ │
│ │ │
│ ▼ │
│ ┌─────────────────────┐ │
│ │ Load server entry │ ← ssrLoadModule │
│ │ Call render(url) │ │
│ └──────────┬──────────┘ │
│ │ │
│ ▼ │
│ ┌─────────────────────┐ │
│ │ Replace placeholder │ │
│ │ with rendered HTML │ │
│ └──────────┬──────────┘ │
│ │ │
│ ▼ │
│ ┌─────────────────────┐ │
│ │ Send the HTML │ │
│ │ to the browser │ │
│ └─────────────────────┘ │
└──────────────────────────────────────────────────────────────┘
The Two Entries
┌──────────────────────────────────────────────────────────────┐
│ CLIENT AND SERVER ENTRIES │
│ │
│ src/main.js (universal) │
│ │ │
│ ┌────┴────┐ │
│ ▼ ▼ │
│ entry- entry- │
│ client server │
│ ┌───────┐ ┌───────┐ │
│ │hydrate│ │render │ │
│ │Root │ │ToString│ │
│ └───────┘ └───────┘ │
│ │
│ The same app code is imported by both entries. │
│ The framework's hydrate and render functions differ. │
└──────────────────────────────────────────────────────────────┘
The Low-Level API vs the Framework
┌──────────────────────────────────────────────────────────────┐
│ WHAT VITE PROVIDES VS WHAT THE FRAMEWORK PROVIDES │
│ │
│ Vite (low-level) Framework │
│ ┌─────────────────────┐ ┌─────────────────────┐ │
│ │ Middleware mode │ │ Router │ │
│ │ transformIndexHtml │ │ Data fetching │ │
│ │ ssrLoadModule │ │ Production server │ │
│ │ ssrFixStacktrace │ │ Deployment model │ │
│ │ import.meta.env.SSR │ │ Hydration │ │
│ └─────────────────────┘ │ Render function │ │
│ └─────────────────────┘ │
│ │
│ Vite provides the primitives. │
│ The framework provides the application. │
└──────────────────────────────────────────────────────────────┘
Summary
| Item | Value |
|---|---|
| API level | Low-level, for library and framework authors |
| Middleware mode | server.middlewareMode: true, appType: 'custom' |
| HTML transform | vite.transformIndexHtml(url, html) |
| Server module load | vite.ssrLoadModule(path) |
| Stack trace fix | vite.ssrFixStacktrace(error) |
| SSR flag | import.meta.env.SSR |
| Source structure | index.html, main.js, entry-client.js, entry-server.js |
| Production build | vite build and vite build --ssr |
Key takeaways:
- Vite’s SSR support is a low-level API, not a framework. It provides the middleware mode, the HTML transform, the server module load, and the SSR flag. It does not provide a router, a data-fetching layer, or a production server. Those are the framework’s responsibility .
- The middleware mode is the foundation. The
server.middlewareMode: trueandappType: 'custom'options let Vite run inside a Node.js server. The parent server handles the routing and the response, and Vite handles the transforms . ssrLoadModuleloads the server entry without a bundle. The module is transformed on demand, and the invalidation is similar to HMR. The server entry is always up to date, and the developer does not wait for a server bundle .- The
import.meta.env.SSRflag branches the logic. It is statically replaced at build time, so the unused branch is removed by tree-shaking. A module that readswindowonly runs in the client branch, and a module that reads a database only runs in the server branch . - The production build is two builds. The client build produces the browser assets, and the server build produces the Node.js entry. The production server uses the built files, not the middleware mode .
Remember: Vite’s SSR support is the set of primitives that a framework builds on. The middleware mode is the server integration. The transformIndexHtml is the HTML pipeline. The ssrLoadModule is the server module loader. The import.meta.env.SSR flag is the environment branch. The primitives are small, and the framework is large. The low-level API is the foundation, and the framework is the application. A developer who uses the low-level API directly is building a framework, or a very custom server. A developer who uses a framework is using the primitives without seeing them. Both are valid, and both depend on the four primitives.
Stop using slow, ad-bloated tool sites! 🤮
🔎 Search “KandZ Tools” on Google to use many professional utilities for free.
KandZ.me is the ultimate minimalist hub for:
✅ Finance (Mortgage, Interest, Inflation)
✅ Tech (Base64, JSON, Dev Suite, IP)
✅ Health (BMI, BMR, TDEE)
✅ Productivity (Timer, Workspace, QR)
⚡️ Fast & Private
🔒 No data leaves your device
💎 100% Free
🔗 Use it now: https://tools.kandz.me
🔖 Bookmark it—you’ll need it later!