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 HTML the server rendered differs from what React renders in the browser on its first pass. In framework mode, React Router hydrates the whole document, so a date in a route, a clientLoader that returns other data, or an attribute an extension adds to <body> can each cause it.

Common hydration errors in React Router

React Router has no hydration messages of its own. A React Router v7 hydration error, or one in a later version, is React's message, and with React 19 it reads:

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 words the same bugs differently; all React hydration error messages lists every variant, and production builds show a minified code such as #418.

Why does a React Router hydration mismatch happen?

The default entry.client.tsx calls hydrateRoot(document, <HydratedRouter />): React owns <html>, <head> and <body>, and compares all of them. The common causes:

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

A common React Router v7 hydration mismatch comes from client data. The React Router docs say it directly: without a HydrateFallback, the route component is rendered on the server and the clientLoader runs during hydration, so the loader and the clientLoader must return the same data on the first load.

How to fix a React Router hydration error

Render the same thing first

Render output that does not depend on the browser, then change it in useEffect, which only runs in the browser 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>;
}

For values the server knows (the user's locale from a cookie, the time of the request), return them from the loader and render from loaderData on both sides.

Render a fallback during hydration in React Router

When a route needs data that only the browser has, set clientLoader.hydrate = true and export a HydrateFallback. React Router then renders the fallback on the server and while the page hydrates, and renders the route component only once the clientLoader has finished. The server and the first client render agree, because both are the fallback.

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 the React Router guide to client data for the other patterns.

Keep suppressHydrationWarning for small, known differences

suppressHydrationWarning on an element tells React to keep the server's text without reporting it, one level deep. It suits a timestamp whose first value does not matter; everywhere else it hides a real bug. See when suppressHydrationWarning is safe.

Test every route with hydration-proof

hydration-proof loads every route in a real browser and compares the server HTML with the hydrated DOM. The react-router adapter is picked when package.json has @react-router/dev or the project has a react-router.config.* file. It supports framework mode; the package's fixtures run version 8.4.

  • 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

Dynamic routes need example values, or they are skipped:

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

If you serve the build with your own server instead of react-router-serve, set server.command (and server.build, since a custom start command only gets a build step when one is configured) and keep the react-router adapter for its routes. The ESLint plugin's recommended preset catches the clock, random values and browser globals in route components as you type.