Hydration Proof

Search documentation

Find a page or section

Vite SSR hydration errors

entry-server renders, entry-client hydrates.

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:

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 hydrates the same tree in the browser:

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>
);

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:

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 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:

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 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 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.

A Vite app has no file-based routes, so list them in the config. Without any routes, 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

--sitemap adds the pages in your sitemap. The ESLint plugin catches the clock, random values and browser globals in components as you type.