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.
Target Architecture
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 hydrateRootThe key dev/prod difference is where the server entry comes from:
| Phase | Server entry source | Why |
|---|---|---|
| dev | Vite-transformed source | HMR, source stacks, on-demand transforms |
| prod | dist/server/entry-server.js | no dev server in production |
SSR apps usually build twice: client output for browsers, server output for the runtime.
Project Structure
examples/ssr-with-env-api/
├── package.json
├── vite.config.ts
├── server.js
├── index.html
└── src/
├── App.tsx
├── main.tsx
└── entry-server.tsx| File | Runtime | Job |
|---|---|---|
src/App.tsx | client + server | shared React tree |
src/entry-server.tsx | server | render React to HTML |
src/main.tsx | client | hydrate server HTML |
Vite Config
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
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
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
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
<!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:
interface ServerEntry {
render(url: string): Promise<{ html: string; data: unknown }>
}
interface ServerEntryLoader {
load(url: string): Promise<ServerEntry>
}Compatible dev implementation:
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:
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
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
{
"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
- Visit
http://localhost:3000. - View page source and confirm
#rootcontains HTML. - Click the button and confirm hydration works.
- Edit
src/App.tsxand confirm dev updates. - Run
pnpm build && pnpm preview. - Throw inside
entry-server.tsxand 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
- Why do SSR apps usually build client and server outputs separately?
- Why must dev mode run
vite.middlewaresfirst? - What is the abstraction difference between
ssrLoadModuleandModuleRunner.import()? - Why inject initial data instead of refetching on the client?
- How do you verify that SSR actually happened?
// 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