# Remix hydration errors

> A Remix hydration error means the server HTML and the first client render differ. Common causes in Remix v2, ClientOnly, HydrateFallback, and route tests.

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

A Remix hydration error means the HTML the server rendered differs from what
React renders in the browser on its first pass. Remix v2 hydrates the whole
document, so the cause can be anywhere: a date in a route, a `clientLoader`
that returns other data, or an attribute a browser extension adds to `<body>`.

## Common hydration errors in Remix

Remix shows React's own messages. With React 18, the version Remix v2 declares
as its peer dependency, they read:

```text
Hydration failed because the initial UI does not match what was rendered on the server.
Text content does not match server-rendered HTML.
There was an error while hydrating. Because the error happened outside of a Suspense boundary, the entire root will switch to client rendering.
```

Production builds show only a minified code such as
[#418](https://hydration.jscrate.dev/docs/errors/minified-react-error-418).
[All React hydration error messages](https://hydration.jscrate.dev/docs/errors) lists every variant.

The default `entry.client.tsx` calls `hydrateRoot(document, <RemixBrowser />)`,
so React compares `<html>`, `<head>` and `<body>` as well as your routes. The
usual causes:

| Cause               | In Remix                                                                         | Fix                                                                       |
| ------------------- | -------------------------------------------------------------------------------- | ------------------------------------------------------------------------- |
| Client data         | A `clientLoader` with `hydrate = true` that returns other data than the `loader` | [Below](#render-a-fallback-while-the-client-loader-runs)                  |
| 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>`                             | [Extensions](https://hydration.jscrate.dev/docs/causes/extension)                                      |

## How to fix a Remix hydration error

### Render a component only in the browser

The `ClientOnly` component from [remix-utils](https://github.com/sergiodxa/remix-utils)
renders its fallback on the server and during hydration, then the real
component. Its children are a function, so the component is not even created on
the server:

```tsx title="app/routes/stores.tsx"
import { ClientOnly } from "remix-utils/client-only";
import { StoreMap, StoreMapPlaceholder } from "~/components/store-map";

export default function Stores() {
  return (
    <ClientOnly fallback={<StoreMapPlaceholder />}>
      {() => <StoreMap />}
    </ClientOnly>
  );
}
```

remix-utils 7.x is the line for Remix v2; newer majors target React Router.
Without a library, the same pattern is a `useEffect` that flips a flag after
hydration; see [client-only components](https://hydration.jscrate.dev/docs/guides/client-only-component).

### Render a fallback while the client loader runs

When a route needs data only the browser has, set `clientLoader.hydrate = true`
and export a `HydrateFallback`. Remix renders the fallback on the server and
calls the `clientLoader` during hydration. Without a `HydrateFallback`, the
route component is rendered on the server, so the `loader` and the
`clientLoader` must return the same data on the first load.

```tsx title="app/routes/game.tsx"
import { useLoaderData } from "@remix-run/react";
import { GameBoard, loadLocalGameData } from "~/game";

export async function clientLoader() {
  return loadLocalGameData();
}
clientLoader.hydrate = true;

export function HydrateFallback() {
  return <p>Loading game…</p>;
}

export default function Game() {
  const data = useLoaderData<typeof clientLoader>();
  return <GameBoard data={data} />;
}
```

### Return server values from the loader

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 `useLoaderData()` on
both sides. The browser then renders with the same value the server used.

## 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 `remix` [adapter](https://hydration.jscrate.dev/docs/adapters) is picked
when `package.json` has `@remix-run/dev`:

|             | With Vite                             | Classic compiler               |
| ----------- | ------------------------------------- | ------------------------------ |
| Build       | `remix vite:build`                    | `remix build`                  |
| Start       | `remix-serve ./build/server/index.js` | `remix-serve ./build/index.js` |
| Development | `remix vite:dev`                      | `remix dev`                    |

The Vite commands are used when the project has a `vite.config.*` file. Your
own `build` script is used when it runs `remix`.

- **Routes:** from `remix routes --json`, with `:id`, `*` and `:lang?`
  converted to `[id]`, `[...slug]` and `[[lang]]`.
- **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.__remixRouter.navigate` is compared with loading each route directly.

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

Give dynamic routes 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"] },
  },
});
```

Remix v2's successor is React Router's framework mode. When you upgrade, the
`react-router` adapter takes over; see
[React Router hydration errors](https://hydration.jscrate.dev/docs/frameworks/react-router).

## Related

- [React Router hydration errors](https://hydration.jscrate.dev/docs/frameworks/react-router)
- [Client-only components](https://hydration.jscrate.dev/docs/guides/client-only-component)
- [useEffect and two-pass rendering](https://hydration.jscrate.dev/docs/guides/useeffect-two-pass-rendering)
- [All causes of hydration errors](https://hydration.jscrate.dev/docs/causes)
- [Test every route with the CLI](https://hydration.jscrate.dev/docs/routes)
