Hydration Proof

Search documentation

Find a page or section

Vite SSR hydration errors

entry-server renders, entry-client hydrates.

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:

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

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

CauseTypical codeFix
Different treesA provider, wrapper or route rendered by one entry and not the otherRender the same App with the same props in both entries
Different dataThe server renders with data the client fetches againServer and client data
The clocknew Date() or Date.now() in a componentTime
Locale and timezonetoLocaleString() without an explicit locale or timeZoneLocale, timezone
Browser-only APIswindow, localStorage or matchMedia read during renderBrowser APIs
Generated idscounters or random ids instead of useIdGenerated 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:

vite.config.ts
import react from "@vitejs/plugin-react";
import { defineConfig } from "vite";
 
export default defineConfig({
  plugins: [react()],
  resolve: { dedupe: ["react", "react-dom"] },
});

How to fix them

  1. Render the same tree. Both entries render one App component 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.
  2. 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.
  3. Read browser values after hydration. Render a neutral value first and change it in useEffect. See useEffect and two-pass rendering.
  4. Use useId for ids. With several React roots on one page, give each its own identifierPrefix, the same on the server and in hydrateRoot.

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 build script, then your start script (or serve, or preview). The app is built only when dist is missing.
  • Development mode: your dev script, with --mode development or --mode both.
  • Port: the server must listen on the port hydration-proof chooses. It is in the PORT environment 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:

hydration-proof.config.ts
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

Use --sitemap to include sitemap pages. The ESLint plugin can flag time, random values, and browser globals during render.