Hydration Proof

Search documentation

Find a page or section

Remix v2, with Vite or the classic compiler.

A Remix hydration error means the first browser render differs from the server HTML. Remix v2 hydrates the whole document, so check route content and the document itself. A date, different clientLoader data, or an extension-added attribute on <body> can cause it.

Common hydration errors in Remix

Remix shows React's hydration messages. With React 18, you may see:

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 may show only a code such as #418. See React hydration error messages for the variants.

The default entry.client.tsx hydrates the document. React checks <html>, <head>, and <body> as well as your routes. Look for:

CauseIn RemixFix
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>Extensions

How to fix a Remix hydration error

Render a component only in the browser

The remix-utils ClientOnly component shows a fallback on the server and during hydration. It renders the real component afterwards:

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

Use a remix-utils version compatible with your Remix app. You can also build the same pattern with useEffect; see client-only components.

Render a fallback while the client loader runs

When a route needs browser-only data, set clientLoader.hydrate = true and export HydrateFallback. Remix shows the fallback on the server while the client loader runs. Without a fallback, the server loader and browser clientLoader must give the route the same initial data.

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

If the server knows the value, such as a locale in a cookie, return it from the loader. Use useLoaderData() to render that same value in the browser.

Test every route with hydration-proof

hydration-proof tests routes in a browser and compares server and client output. It selects the remix adapter when it finds @remix-run/dev:

With ViteClassic compiler
Buildremix vite:buildremix build
Startremix-serve ./build/server/index.jsremix-serve ./build/index.js
Developmentremix vite:devremix dev

The adapter uses the Vite commands when it finds vite.config.*. It can use your build script when that script 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.
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 move to React Router framework mode, use its adapter and framework guide.