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:
| Cause | In Remix | Fix |
|---|---|---|
| Client data | A clientLoader with hydrate = true that returns other data than the loader | Below |
| The clock | new Date() or Date.now() in a route component | Time |
| Locale and timezone | toLocaleString() without an explicit locale or timeZone | Locale, timezone |
| Browser-only APIs | window, localStorage or matchMedia read during render | Browser APIs, storage |
| Invalid HTML | <div> inside <p>, <a> inside <a> | Invalid nesting |
| The document | Attributes 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:
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.
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 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 throughwindow.__remixRouter.navigateis compared with loading each route directly.
npm install -D hydration-proof
npx hydration-proof install
npx hydration-proof test --mode bothGive dynamic routes example values, or they are skipped:
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.