Vite SSR hydration works in two halves: entry-server renders your app to HTML
with renderToString or renderToPipeableStream, and entry-client calls
hydrateRoot on that HTML. A hydration error means the two halves rendered
different output, usually because of the clock, the locale, a browser API, or
different data on each side.
How Vite SSR hydration works
A Vite SSR app has an index.html with a placeholder for the server markup, a
server (server.js) that fills it in, and two entry files. The server entry
renders the app:
import { StrictMode } from "react";
import { renderToString } from "react-dom/server";
import App from "./App";
export function render(url: string) {
const html = renderToString(
<StrictMode>
<App url={url} />
</StrictMode>
);
return { html };
}The client entry hydrates the same tree in the browser:
import { StrictMode } from "react";
import { hydrateRoot } from "react-dom/client";
import App from "./App";
hydrateRoot(
document.getElementById("root")!,
<StrictMode>
<App url={window.location.pathname} />
</StrictMode>
);Everything App renders on its first pass in the browser must match what
render produced on the server: the same elements, the same text, the same
attributes. The Vite SSR guide covers the server
side, and its create-vite-extra templates include React.
Common hydration errors in Vite SSR
A Vite hydration error is React's own message, logged in the browser console. With React 19:
Hydration failed because the server rendered HTML didn't match the client. As a result this tree will be regenerated on the client.
A tree hydrated but some attributes of the server rendered HTML didn't match the client properties. This won't be patched up.All React hydration error messages lists the React 18 wording and the minified production codes. The causes:
| Cause | Typical code | Fix |
|---|---|---|
| Different trees | A provider, wrapper or route rendered by one entry and not the other | Render the same App with the same props in both entries |
| Different data | The server renders with data the client fetches again | Server and client data |
| The clock | new Date() or Date.now() in a component | Time |
| Locale and timezone | toLocaleString() without an explicit locale or timeZone | Locale, timezone |
| Browser-only APIs | window, localStorage or matchMedia read during render | Browser APIs |
| Generated ids | counters or random ids instead of useId | Generated ids |
Two copies of React
If the server bundle and a linked package resolve different copies of react,
hooks throw "Invalid hook call" and the page fails instead of hydrating. This
happens with hoisting and with linked packages in monorepos. Vite's
resolve.dedupe
forces one copy; read its note on SSR builds before relying on it:
import react from "@vitejs/plugin-react";
import { defineConfig } from "vite";
export default defineConfig({
plugins: [react()],
resolve: { dedupe: ["react", "react-dom"] },
});How to fix them
- Render the same tree. Both entries render one
Appcomponent with the same props. Anything the server knows (the URL, the user's locale from a header or cookie) goes in as a prop, and the client reads it from the page. - Send the server's data along. Serialize the data the server rendered with into the page and hydrate from it, instead of fetching again in the browser. See different data.
- Read browser values after hydration. Render a neutral value first and
change it in
useEffect. See useEffect and two-pass rendering. - Use
useIdfor ids. With several React roots on one page, give each its ownidentifierPrefix, the same on the server and inhydrateRoot.
Test every route with hydration-proof
hydration-proof starts your server, loads each route in a real browser and
compares the server HTML with the hydrated DOM. The vite
adapter is picked when package.json has vite and
react-dom, and the project has a server.js (or server.mjs, server.ts)
or a src/entry-server.* file:
- Build and start: your
buildscript, then yourstartscript (orserve, orpreview). The app is built only whendistis missing. - Development mode: your
devscript, with--mode developmentor--mode both. - Port: the server must listen on the port hydration-proof chooses. It is
in the
PORTenvironment variable, and{port}in a configured command is replaced with it. - Framework markup: Vite's client script and the React Refresh preamble are never compared.
- Streaming: with
renderToPipeableStream, Suspense boundaries that hydrate later are compared on their own.
A Vite app has no file-based routes, so list them in the config. Without any
routes, only / is tested:
import { defineConfig } from "hydration-proof";
export default defineConfig({
routes: {
paths: ["/", "/about", "/products/1"],
crawl: true, // also follow links found on those pages
},
});npm install -D hydration-proof
npx hydration-proof install
npx hydration-proof test--sitemap adds the pages in your sitemap. The
ESLint plugin catches the clock, random values and browser
globals in components as you type.
Related
- Custom React servers with Express and streaming
- Astro hydration errors
- What is hydration in React?
- Test every route for hydration errors
- Adapters