| | |

Vite 24 ⚡ SSR Fundamentals — Vite’s low-level server-side rendering APIs

⏱️ Estimated read: 15 min | |

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

PrimitivePurposeWhere
server.middlewareMode: trueRun Vite as middlewarecreateViteServer options
appType: 'custom'Disable Vite’s HTML servingcreateViteServer options
vite.transformIndexHtml(url, html)Apply HTML transformsThe catch-all route
vite.ssrLoadModule(path)Load a module for the serverThe catch-all route
vite.ssrFixStacktrace(error)Rewrite the error stack traceThe catch block
import.meta.env.SSRBranch server/client logicAny module

The source structure

FilePurpose
index.htmlThe template with the placeholder
src/main.jsThe universal application code
src/entry-client.jsThe browser mount
src/entry-server.jsThe server render function
server.jsThe Express server with Vite middleware

The SSR options

OptionPurpose
ssr.externalPackages to externalize (not bundle)
ssr.noExternalPackages to bundle (ESM-only, CSS)
ssr.targetThe 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

PitfallWhy It HappensFix
HMR does not work in SSRThe HTML transforms were not appliedCall transformIndexHtml
The server entry is staleThe module was bundled, not loadedUse ssrLoadModule
The stack trace points to transformed codessrFixStacktrace was not calledCall it in the catch block
The package breaks in the server bundleThe package ships CSS or ESM-only codeAdd it to ssr.noExternal
The client entry is missingThe index.html does not reference itAdd 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

ItemValue
API levelLow-level, for library and framework authors
Middleware modeserver.middlewareMode: true, appType: 'custom'
HTML transformvite.transformIndexHtml(url, html)
Server module loadvite.ssrLoadModule(path)
Stack trace fixvite.ssrFixStacktrace(error)
SSR flagimport.meta.env.SSR
Source structureindex.html, main.js, entry-client.js, entry-server.js
Production buildvite 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: true and appType: 'custom' options let Vite run inside a Node.js server. The parent server handles the routing and the response, and Vite handles the transforms .
  • ssrLoadModule loads 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.SSR flag branches the logic. It is statically replaced at build time, 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 .
  • 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.

Was this helpful?


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!