Nitro logoNitro

SSR with React

Server-side rendering with React in Nitro using Vite.
src/entry-server.tsx
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:

vite.config.mjs
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:

app.tsx
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:

entry-server.tsx
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:

entry-client.tsx
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.

Learn More