A browser API hydration mismatch happens when a component reads window,
document, navigator or location while it renders, or branches on
typeof window. The server takes one branch, the browser takes the other
during hydration, and the HTML differs. Read browser values in useEffect, or
render the component on the client only.
Symptoms
The error depends on what the branch changes: text, an attribute, a style or a whole element.
Hydration failed because the server rendered HTML didn't match the client.
Warning: Text content did not match. Server: "Rendered for server" Client: "Rendered for Chromium"
Warning: Prop `href` did not match. Server: "/pricing?region=eu-west" Client: "/pricing?region=us-east"
Warning: Expected server HTML to contain a matching <canvas> in <div>.hydration-proof reports text as HP1001, attributes as HP1002 and inline styles as HP1003, with the cause Browser-only API used during render. Attribute and style differences are the dangerous ones: React 19 does not report or fix them in production, so the page keeps the server's value.
The values alone rarely show this cause, so hydration-proof finds it in the
code: it maps the element to its source line and looks for typeof window,
navigator., window. and flags like isServer or canUseDOM in the
component. That is why its confidence is lower (57–59% in the test suite) and
why development builds, with exact source lines, name it most reliably.
Why a browser API hydration mismatch happens
The server renders in Node.js, where window, document and location do
not exist. Code that needs them has to do something else on the server, and
that something else is what the browser then fails to match.
Why does typeof window cause a hydration mismatch?
The check exists to keep the server from crashing with
ReferenceError: window is not defined. It does that, but it also makes the
two renders take different branches:
export function Chart({ data }: { data: number[] }) {
// false on the server, true during hydration: two different trees
if (typeof window === "undefined")
return <div className="chart-placeholder" />;
return <Canvas data={data} />;
}React compares the first client render with the server HTML, not with what
the server would have rendered in a browser. Any value that exists only in the
browser (innerWidth, navigator.language, location.hostname,
document.cookie, devicePixelRatio) has the same effect, with or without the
check.
typeof navigator is not a safe test either: Node.js 21 and later have a
navigator global, so the check is true on the server and
navigator.userAgent there describes Node.js.
How to fix it
Read browser values in useEffect
Start from a value the server can render, and switch after hydration:
"use client";
import { useEffect, useState } from "react";
export function Greeting() {
// Before: typeof window !== "undefined" ? window.location.hostname : null
const [host, setHost] = useState<string | null>(null);
useEffect(() => {
setHost(window.location.hostname);
}, []);
return <p>{host ? `Hello from ${host}` : "Hello"}</p>;
}useEffect and two-pass rendering covers when the switch is visible and how to keep layout from jumping.
Know when you are on the client with useSyncExternalStore
A hook that returns false during server rendering and hydration, and true
afterward, replaces every typeof window check in render:
import { useSyncExternalStore } from "react";
const subscribe = () => () => {};
export function useIsClient() {
return useSyncExternalStore(
subscribe,
() => true, // in the browser, after hydration
() => false // on the server and during hydration
);
}"use client";
import { useIsClient } from "./use-is-client";
export function Chart({ data }: { data: number[] }) {
const isClient = useIsClient();
if (!isClient) return <div className="chart-placeholder" />;
return <Canvas data={data} />;
}The same pattern works for any browser value with a sensible server default:
useSyncExternalStore(subscribe, () => navigator.onLine, () => true).
Render the component on the client only
When a component cannot render on the server at all (a map, a chart library that
touches window on import), skip it during server rendering. In Next.js, load
it with next/dynamic and ssr: false from a Client Component:
"use client";
import dynamic from "next/dynamic";
const Map = dynamic(() => import("./map"), {
ssr: false,
loading: () => <div className="map-placeholder" />,
});
export function MapSection() {
return <Map />;
}The server renders the placeholder, and so does the first client render. See next/dynamic with ssr: false and client-only components for other frameworks.
Use the framework instead of globals
Most browser reads have a server-safe equivalent. Use usePathname() or your
router's hook instead of location.pathname, and read request headers such as
user-agent on the server and pass the result down, instead of reading
navigator.userAgent in render.
Catch it with ESLint
Three rules cover this cause, each for a different pattern:
no-browser-global-in-renderreportswindow,document,navigator,location,innerWidthand other browser globals in render code.no-window-render-branchreportstypeof windowchecks and flags likeisServer,isBrowserandcanUseDOMused as conditions.no-client-only-initial-statereports browser values inuseState,useReduceranduseRefinitializers.
require-stable-server-snapshot
makes sure a useSyncExternalStore server snapshot does not read the browser.
npm install -D eslint-plugin-hydration-proofCatch it in CI
hydration-proof test reports each difference as HP1001, HP1002, HP1003 or a
form state difference (HP1012), including the attribute
and style differences React stays silent about. Run in development mode for
exact source lines, or in both modes to see what production users get:
npx hydration-proof test --mode bothNo probe factor flips a typeof window branch, so the cause
comes from the code. When a probe changes the value, the cause is more specific:
storage, media queries or
theme.