Hydration Proof

Search documentation

Find a page or section

Remix v2, with Vite or the classic compiler.

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:

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. All React hydration error messages 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:

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 ClientOnly component from 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:

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.

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.

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 is picked when package.json has @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 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.
npm install -D hydration-proof
npx hydration-proof install
npx hydration-proof test --mode both

Give dynamic routes example values, or they are skipped:

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.