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 render code uses window, document, navigator, or location. The server and browser then produce different output. Start with a value both can render, and read the browser in useEffect. If the component cannot run on the server, load it only in the browser.

Symptoms

The message depends on whether the changed output is text, an attribute, a style, or an 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 styles as HP1003. React 19 may leave a mismatched attribute in place without reporting it in production, so inspect the page as well as the console.

The changed values rarely identify the browser API by themselves. hydration-proof also checks source code near the element for browser globals and server/client branches. Development builds provide better source locations, so they can give a more useful cause.

Why a browser API hydration mismatch happens

The server does not have the browser's window, document, or location. If render code falls back to another value there, React compares that fallback with the browser's value during hydration.

Why does typeof window cause a hydration mismatch?

Checking typeof window avoids a server crash, but it can still make 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 browser render with the HTML actually sent by the server. Browser-only values such as innerWidth, navigator.language, and document.cookie can change that output whether or not you guard the read.

Do not assume typeof navigator means "in a browser" either. Some Node.js versions have a navigator global with server-specific values.

How to fix it

Read browser values in useEffect

Start with the same value on both sides. Update it 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>;
}

See two-pass rendering for the visual tradeoff and ways to avoid layout shifts.

Know when you are on the client with useSyncExternalStore

A hook can return false on the server and during hydration, then true afterward. This gives components a stable first 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 a browser value that has a sensible server default, such as online status.

Render the component on the client only

If a map or chart library reads window when imported, an effect is too late: the import itself must happen only in the browser. In Next.js, call next/dynamic with 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 />;
}

Both sides render the placeholder first. See next/dynamic with ssr: false and client-only components for more options.

Use the framework instead of globals

Before adding an effect, look for a server-safe source. Use your router's pathname hook instead of location.pathname. Read request headers on the server and pass their values down when the page needs them.

Catch it with ESLint

These rules catch different browser-only reads:

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

Run the browser check to find affected pages. Use both modes if you need development source locations and production behavior:

npx hydration-proof test --mode both

A probe cannot flip a typeof window branch. If a probe does change the finding, look for a more specific cause such as storage, media queries, or theme.