Hydration Proof

Search documentation

Find a page or section

React Router hydration errors

Framework mode, loaders and client loaders.

A React Router hydration error means the first browser render differs from the server HTML. In framework mode, React hydrates the whole document. A date in a route, different clientLoader data, or an extension that changes <body> can all cause a mismatch.

Common hydration errors in React Router

A React Router v7 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.

React 18 uses different wording. See React hydration error messages for the variants. Production may show only a code such as #418.

Why does a React Router hydration mismatch happen?

The default entry.client.tsx hydrates the document. React checks <html>, <head>, and <body>, not only the route content. Look for:

CauseIn React RouterFix
Client dataA clientLoader with hydrate = true that returns other data than the loaderBelow
The clocknew Date() or Date.now() in a route componentTime
Locale and timezonetoLocaleString() without an explicit locale or timeZoneLocale, timezone
Browser-only APIswindow, localStorage or matchMedia read during renderBrowser APIs, storage
Invalid HTML<div> inside <p>, <a> inside <a>Invalid nesting
The documentAttributes an extension adds to <html> or <body>, tags a script adds to <head>Extensions, scripts

One common React Router v7 hydration mismatch comes from client data. Without a HydrateFallback, the route component renders on the server and the clientLoader runs during hydration. The first results from loader and clientLoader must agree.

How to fix a React Router hydration error

Render the same thing first

For a browser-only value, start with a stable value and update it in useEffect after hydration:

app/routes/greeting.tsx
import { useEffect, useState } from "react";
 
export default function Greeting() {
  const [name, setName] = useState<string | null>(null);
 
  useEffect(() => {
    setName(localStorage.getItem("name"));
  }, []);
 
  return <p>Hello{name ? `, ${name}` : ""}</p>;
}

If the server knows the value, return it from the loader and use the same loaderData on both sides.

Render a fallback during hydration in React Router

When a route needs browser-only data, set clientLoader.hydrate = true and export HydrateFallback. React Router shows the fallback on the server and during hydration. It shows the route after clientLoader finishes.

app/routes/game.tsx
import type { Route } from "./+types/game";
import { GameBoard, loadLocalGameData } from "../game";
 
export async function clientLoader() {
  return loadLocalGameData();
}
clientLoader.hydrate = true as const;
 
export function HydrateFallback() {
  return <p>Loading game…</p>;
}
 
export default function Game({ loaderData }: Route.ComponentProps) {
  return <GameBoard data={loaderData} />;
}

See React Router's client data guide for other patterns.

Keep suppressHydrationWarning for small, known differences

suppressHydrationWarning hides one expected text or attribute difference. It does not fix the page. See when it is safe.

Test every route with hydration-proof

hydration-proof tests routes in a browser and compares the server HTML with the first client render. It selects the react-router adapter when it finds @react-router/dev or a react-router.config.* file. The adapter supports framework mode.

  • Build and start: react-router build (or your build script when it runs react-router build), then react-router-serve ./build/server/index.js. The app is built only when build/server/index.js is missing.
  • Development mode: react-router dev, with --mode development or --mode both.
  • Routes: from react-router routes --json, in app/ or src/app/. :id, * and :lang? become [id], [...slug] and [[lang]], the syntax routes.dynamic uses.
  • Not-found page: a URL that does not exist is loaded too, to check that the not-found page hydrates.
  • Navigation: with --navigation, a client-side navigation through window.__reactRouterDataRouter.navigate is compared with loading each route directly.
npm install -D hydration-proof
npx hydration-proof install
npx hydration-proof test --mode both

Provide example values for dynamic routes so the check can visit them:

hydration-proof.config.ts
import { defineConfig } from "hydration-proof";
 
export default defineConfig({
  routes: {
    dynamic: { "/products/[id]": ["your-stable-test-product-id"] },
  },
});

If you use a custom server, set server.command and server.build. Keep the react-router adapter for route discovery. The ESLint plugin can catch time, random values, and browser globals during render.