SSR with React
import "./styles.css";
import { renderToReadableStream } from "react-dom/server.edge";
import { App } from "./app.tsx";
import clientAssets from "./entry-client?assets=client";
import serverAssets from "./entry-server?assets=ssr";
export default {
async fetch(_req: Request) {
const assets = clientAssets.merge(serverAssets);
return new Response(
await renderToReadableStream(
<html lang="en">
<head>
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
{assets.css.map((attr: any) => (
<link key={attr.href} rel="stylesheet" {...attr} />
))}
{assets.js.map((attr: any) => (
<link key={attr.href} type="modulepreload" {...attr} />
))}
<script type="module" src={assets.entry} />
</head>
<body id="app">
<App />
</body>
</html>
),
{ headers: { "Content-Type": "text/html;charset=utf-8" } }
);
},
};
Set up server-side rendering (SSR) with React, Vite, and Nitro. This setup enables streaming HTML responses, automatic asset management, and client hydration.
Overview
Add the Nitro Vite plugin to your Vite config
Configure client and server entry points
Create a server entry that renders your app to HTML
Create a client entry that hydrates the server-rendered HTML
1. Configure Vite
Add the Nitro and React plugins to your Vite config. Define the client environment with your client entry point:
import { defineConfig } from "vite";
import { nitro } from "nitro/vite";
import react from "@vitejs/plugin-react";
export default defineConfig({
plugins: [nitro(), react()],
environments: {
client: {
build: { rollupOptions: { input: "./src/entry-client.tsx" } },
},
},
});
The environments.client configuration tells Vite which file to use as the browser entry point. Nitro automatically detects the server entry from files named entry-server or server in common directories.
2. Create the App Component
Create a shared React component that runs on both server and client:
import { useState } from "react";
export function App() {
const [count, setCount] = useState(0);
return (
<>
<h1 className="hero">Nitro + Vite + React</h1>
<button onClick={() => setCount((c) => c + 1)}>Count is {count}</button>
</>
);
}
3. Create the Server Entry
The server entry renders your React app to a streaming HTML response. It uses react-dom/server.edge for edge-compatible streaming:
import "./styles.css";
import { renderToReadableStream } from "react-dom/server.edge";
import { App } from "./app.tsx";
import clientAssets from "./entry-client?assets=client";
import serverAssets from "./entry-server?assets=ssr";
export default {
async fetch(_req: Request) {
const assets = clientAssets.merge(serverAssets);
return new Response(
await renderToReadableStream(
<html lang="en">
<head>
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
{assets.css.map((attr: any) => (
<link key={attr.href} rel="stylesheet" {...attr} />
))}
{assets.js.map((attr: any) => (
<link key={attr.href} type="modulepreload" {...attr} />
))}
<script type="module" src={assets.entry} />
</head>
<body id="app">
<App />
</body>
</html>
),
{ headers: { "Content-Type": "text/html;charset=utf-8" } }
);
},
};
Import assets using the ?assets=client and ?assets=ssr query parameters. Nitro collects CSS and JS assets from each entry point, and merge() combines them into a single manifest. The assets object provides arrays of stylesheet and script attributes, plus the client entry URL. Use renderToReadableStream to stream HTML as React renders, improving time-to-first-byte.
4. Create the Client Entry
The client entry hydrates the server-rendered HTML, attaching React's event handlers:
import "@vitejs/plugin-react/preamble";
import { hydrateRoot } from "react-dom/client";
import { App } from "./app.tsx";
hydrateRoot(document.querySelector("#app")!, <App />);
The @vitejs/plugin-react/preamble import is required for React Fast Refresh during development. The hydrateRoot function attaches React to the existing server-rendered DOM without re-rendering it.