Hydration Proof

Search documentation

Find a page or section

Fix a browser API hydration mismatch

The server has no window. Checking for one does not fix that.

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:

chart.tsx
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:

greeting.tsx
"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:

use-is-client.ts
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
  );
}
chart.tsx
"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:

app/map-section.tsx
"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:

require-stable-server-snapshot makes sure a useSyncExternalStore server snapshot does not read the browser.

npm install -D eslint-plugin-hydration-proof

Catch 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 both

No 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.