Nitro logoNitro

Vite SSR with React

View Source
Server-side rendering with React in Nitro using Vite.

Set up server-side rendering (SSR) with React, Vite, and Nitro. This setup enables streaming HTML responses, automatic asset management, and client hydration.

├── src/
│   ├── app.tsx
│   ├── entry-client.tsx
│   ├── entry-server.tsx
│   └── styles.css
├── package.json
├── README.md
├── tsconfig.json
└── vite.config.mjs

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