# React Router hydration errors

> A React Router hydration error means the server HTML and the first client render differ. Causes in framework mode, HydrateFallback, and testing every route.

Source: https://hydration.jscrate.dev/docs/frameworks/react-router
Last updated: 2026-09-18

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:

```text
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](https://hydration.jscrate.dev/docs/errors) lists every variant, and production builds show a
minified code such as [#418](https://hydration.jscrate.dev/docs/errors/minified-react-error-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:

| Cause               | In React Router                                                                      | Fix                                                                              |
| ------------------- | ------------------------------------------------------------------------------------ | -------------------------------------------------------------------------------- |
| Client data         | A `clientLoader` with `hydrate = true` that returns other data than the `loader`     | [Below](#render-a-fallback-during-hydration-in-react-router)                     |
| The clock           | `new Date()` or `Date.now()` in a route component                                    | [Time](https://hydration.jscrate.dev/docs/causes/time)                                                        |
| Locale and timezone | `toLocaleString()` without an explicit locale or `timeZone`                          | [Locale](https://hydration.jscrate.dev/docs/causes/locale), [timezone](https://hydration.jscrate.dev/docs/causes/timezone)                 |
| Browser-only APIs   | `window`, `localStorage` or `matchMedia` read during render                          | [Browser APIs](https://hydration.jscrate.dev/docs/causes/browser-api), [storage](https://hydration.jscrate.dev/docs/causes/storage)        |
| Invalid HTML        | `<div>` inside `<p>`, `<a>` inside `<a>`                                             | [Invalid nesting](https://hydration.jscrate.dev/docs/causes/invalid-html)                                     |
| The document        | Attributes an extension adds to `<html>` or `<body>`, tags a script adds to `<head>` | [Extensions](https://hydration.jscrate.dev/docs/causes/extension), [scripts](https://hydration.jscrate.dev/docs/causes/third-party-script) |

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:

```tsx title="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.

```tsx title="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](https://reactrouter.com/how-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](https://hydration.jscrate.dev/docs/guides/suppresshydrationwarning).

## 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](https://hydration.jscrate.dev/docs/adapters) 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.

```bash
npm install -D hydration-proof
npx hydration-proof install
npx hydration-proof test --mode both
```

Dynamic routes need example values, or they are skipped:

```ts title="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](https://hydration.jscrate.dev/docs/eslint)'s `recommended` preset catches the clock, random
values and browser globals in route components as you type.

## Related

- [Remix hydration errors](https://hydration.jscrate.dev/docs/frameworks/remix), React Router's predecessor
- [Client-only components](https://hydration.jscrate.dev/docs/guides/client-only-component)
- [Test every route for hydration errors](https://hydration.jscrate.dev/docs/routes)
- [All causes of hydration errors](https://hydration.jscrate.dev/docs/causes)
- [Adapters](https://hydration.jscrate.dev/docs/adapters)
