Vite SSR hydration starts with HTML from entry-server. Then entry-client
calls hydrateRoot in the browser. If the first browser render differs from
that HTML, React reports a hydration error. Common causes are time, locale,
browser-only values, and data loaded twice.
How Vite SSR hydration works
A typical Vite SSR app has a server entry that renders the app into an HTML template:
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 must render the same tree:
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>
);The first browser render needs the same elements, text, and attributes as the server render. See the Vite SSR guide for the full setup.
Common hydration errors in Vite SSR
A Vite hydration error uses React's messages. With React 19, you may see:
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.See React hydration error messages for other versions and production codes. These are common 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 a linked package resolves another copy of React, hooks may throw "Invalid
hook call" before hydration. Check dependency resolution in a monorepo. Vite's
resolve.dedupe
can help, but read its SSR limitations first:
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 the app and checks each configured route in a browser.
It selects the vite adapter when it finds vite,
react-dom, and a server entry:
- 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.
List the routes you want checked. Without a route list, 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 testUse --sitemap to include sitemap pages. The ESLint plugin
can flag time, random values, and browser globals during render.
Related
- Custom React servers with Express and streaming
- Astro hydration errors
- What is hydration in React?
- Test every route for hydration errors
- Adapters