vite-mastery

8.2 · difficulty 3/4 · 22 min read

Project 7: Hand-Rolled SSR from ssrLoadModule to ModuleRunner

Build a Vite + Node.js SSR app from scratch, first with the compatible ssrLoadModule path and then with a clean loading abstraction for the Environment API era.

Vite 8.1RC

Target Architecture

text
browser requests /
  -> Node HTTP server
  -> dev: Vite middleware handles /@vite/client, source, and HMR
  -> load server entry
  -> render(url) returns HTML + initial data
  -> transformIndexHtml processes the template
  -> inject <!--ssr-outlet--> and <!--ssr-data-->
  -> browser hydrates with hydrateRoot

The key dev/prod difference is where the server entry comes from:

PhaseServer entry sourceWhy
devVite-transformed sourceHMR, source stacks, on-demand transforms
proddist/server/entry-server.jsno dev server in production

SSR apps usually build twice: client output for browsers, server output for the runtime.

Project Structure

text
examples/ssr-with-env-api/
├── package.json
├── vite.config.ts
├── server.js
├── index.html
└── src/
    ├── App.tsx
    ├── main.tsx
    └── entry-server.tsx
FileRuntimeJob
src/App.tsxclient + servershared React tree
src/entry-server.tsxserverrender React to HTML
src/main.tsxclienthydrate server HTML

Vite Config

ts
import { defineConfig } from "vite"
import react from "@vitejs/plugin-react"

export default defineConfig({
  plugins: [react()],
  environments: {
    client: {},
    ssr: {
      resolve: {
        conditions: ["node", "import"],
        noExternal: ["react", "react-dom"],
      },
    },
  },
  build: {
    manifest: true,
  },
})

plugins: [react()] handles JSX and React transforms on both sides. The ssr environment resolves packages with Node conditions. The example uses noExternal to keep the demo focused; real Node SSR projects often externalize most dependencies and only bundle packages that need Vite transforms.

Shared Component

tsx
interface AppProps {
  url?: string
  data?: { message: string }
}

export function App({ url = "/", data }: AppProps) {
  return (
    <main>
      <h1>Vite SSR Demo</h1>
      <p>Current path: {url}</p>
      {data ? <p>Server data: {data.message}</p> : null}
      <button onClick={() => alert("Client interaction works!")}>Test Hydration</button>
    </main>
  )
}

This component runs on the server to produce HTML and again in the browser to hydrate. Avoid direct window, document, or localStorage access in shared render code.

Server Entry

tsx
import { renderToString } from "react-dom/server"
import { App } from "./App"

export interface RenderResult {
  html: string
  data: { message: string }
}

export async function render(url: string): Promise<RenderResult> {
  const data = {
    message: `Data fetched on the server (${new Date().toISOString()})`,
  }

  const html = renderToString(<App url={url} data={data} />)
  return { html, data }
}

The HTML goes into <!--ssr-outlet-->; the data is serialized for the browser. Without initial data injection, hydration may refetch, flicker, or mismatch the server HTML.

Client Entry

tsx
import { hydrateRoot } from "react-dom/client"
import { App } from "./App"

declare global {
  interface Window {
    __SSR_DATA__?: { message: string }
  }
}

hydrateRoot(document.getElementById("root")!, <App url={window.location.pathname} data={window.__SSR_DATA__} />)

Use hydrateRoot, not createRoot. createRoot is for client-only rendering and would throw away the server HTML.

HTML Template

html
<!doctype html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>Vite SSR</title>
  </head>
  <body>
    <div id="root"><!--ssr-outlet--></div>
    <script>
      window.__SSR_DATA__ = <!--ssr-data-->;
    </script>
    <script type="module" src="/src/main.tsx"></script>
  </body>
</html>

Production code must serialize data safely. Do not blindly inject user-controlled strings into <script>.

Isolate Server Entry Loading

To prepare for ModuleRunner.import(), keep entry loading behind an interface:

ts
interface ServerEntry {
  render(url: string): Promise<{ html: string; data: unknown }>
}

interface ServerEntryLoader {
  load(url: string): Promise<ServerEntry>
}

Compatible dev implementation:

ts
import type { ViteDevServer } from "vite"

export function createSsrLoadModuleLoader(vite: ViteDevServer): ServerEntryLoader {
  return {
    async load(_url) {
      return vite.ssrLoadModule("/src/entry-server.tsx") as Promise<ServerEntry>
    },
  }
}

Future ModuleRunner-shaped implementation:

ts
export function createModuleRunnerLoader(runner: { import<T>(url: string): Promise<T> }): ServerEntryLoader {
  return {
    async load(_url) {
      return runner.import<ServerEntry>("/src/entry-server.tsx")
    },
  }
}

The request handler should not care how the entry is executed.

HTTP Server

js
import { createServer as createHttpServer } from "node:http"
import { readFileSync } from "node:fs"
import { createServer as createViteServer } from "vite"

const isProd = process.argv.includes("--prod")

async function startServer() {
  let vite
  let template

  if (!isProd) {
    vite = await createViteServer({
      server: { middlewareMode: true },
      appType: "custom",
    })
  } else {
    template = readFileSync("dist/client/index.html", "utf-8")
  }

  const server = createHttpServer(async (req, res) => {
    const url = req.url ?? "/"

    if (!isProd && vite) {
      await new Promise((resolve) => {
        vite.middlewares(req, res, resolve)
      })
      if (res.writableEnded) return
    }

    try {
      let appHtml
      let data
      let html

      if (!isProd && vite) {
        const { render } = await vite.ssrLoadModule("/src/entry-server.tsx")
        ;({ html: appHtml, data } = await render(url))

        const rawTemplate = readFileSync("index.html", "utf-8")
        html = await vite.transformIndexHtml(url, rawTemplate)
      } else {
        const { render } = await import("./dist/server/entry-server.js")
        ;({ html: appHtml, data } = await render(url))
        html = template
      }

      html = html.replace("<!--ssr-outlet-->", appHtml).replace("<!--ssr-data-->", JSON.stringify(data))

      res.setHeader("Content-Type", "text/html; charset=utf-8")
      res.end(html)
    } catch (error) {
      if (vite) vite.ssrFixStacktrace(error)
      console.error(error)
      res.statusCode = 500
      res.end(String(error))
    }
  })

  server.listen(3000, () => {
    console.log("SSR server: http://localhost:3000")
  })
}

startServer()

In dev, vite.middlewares must run before SSR rendering so Vite can serve /@vite/client, source modules, CSS, and HMR assets.

Production Scripts

json
{
  "scripts": {
    "dev": "node server.js",
    "build": "vite build --outDir dist/client && vite build --ssr src/entry-server.tsx --outDir dist/server",
    "preview": "node server.js --prod"
  }
}

Production imports dist/server/entry-server.js directly. It should not depend on the Vite dev server.

Verification Checklist

  1. Visit http://localhost:3000.
  2. View page source and confirm #root contains HTML.
  3. Click the button and confirm hydration works.
  4. Edit src/App.tsx and confirm dev updates.
  5. Run pnpm build && pnpm preview.
  6. Throw inside entry-server.tsx and confirm source stack traces.

When Not to Hand-Roll SSR

Hand-rolled SSR is useful for learning or thin internal frameworks. Production apps also need routing, data loading, streaming, head management, preload generation, error boundaries, safe serialization, and platform adapters.

If you need all of that, use an established SSR framework. This chapter helps you understand what those frameworks are wrapping.

Check Yourself

  1. Why do SSR apps usually build client and server outputs separately?
  2. Why must dev mode run vite.middlewares first?
  3. What is the abstraction difference between ssrLoadModule and ModuleRunner.import()?
  4. Why inject initial data instead of refetching on the client?
  5. How do you verify that SSR actually happened?
ts
// Refactor server.js into loader layers:
// - createDevEntryLoader(vite): uses ssrLoadModule
// - createProdEntryLoader(): imports dist/server/entry-server.js
// - request handling only calls loader.load(url)
// - keep vite.transformIndexHtml and ssrFixStacktrace