Hydration Proof

Search documentation

Find a page or section

Fix the Next.js localStorage hydration error

The server has no storage, so it always renders the default.

A localStorage hydration error happens when render code reads a saved browser value. The server has no localStorage or sessionStorage, so its output can differ from the first browser render. Start with a shared default, then read storage after hydration—or supply a server snapshot for a subscription.

Symptoms

Hydration failed because the server rendered text didn't match the client.
Text content does not match server-rendered HTML.
Warning: Text content did not match. Server: "Welcome back, guest" Client: "Welcome back, Sohail"
Warning: Prop `className` did not match. Server: "light" Client: "dark"

The problem often appears only for returning users with a saved value. A fresh browser may not reproduce it.

hydration-proof reports changed text as HP1001, a class as HP1004, or another attribute as HP1002.

Why a localStorage hydration error happens

Storage is a browser API. A read during render can crash on the server, or take a different branch behind a typeof window check:

welcome.tsx
"use client";
 
// Next.js hydration error: local storage read during render.
export function Welcome() {
  const name =
    typeof window === "undefined"
      ? "guest"
      : (localStorage.getItem("name") ?? "guest");
  return <p>Welcome back, {name}</p>;
}

The check prevents a crash, not a mismatch. A useState initializer that reads storage has the same problem because it runs during the first render.

React may re-render changed text. An attribute such as class="light" can instead remain on the page without a production warning.

How to fix it

Render a neutral value, then read storage in useEffect

Render the same default on both sides, then load storage in an effect:

welcome.tsx
"use client";
 
import { useEffect, useState } from "react";
 
export function Welcome() {
  const [name, setName] = useState("guest");
 
  useEffect(() => {
    setName(localStorage.getItem("name") ?? "guest");
  }, []);
 
  return <p>Welcome back, {name}</p>;
}

Subscribe with useSyncExternalStore: localStorage plus a server snapshot

useSyncExternalStore uses a server snapshot during hydration and reads the browser value afterwards:

use-stored-name.ts
import { useSyncExternalStore } from "react";
 
function subscribe(onChange: () => void) {
  window.addEventListener("storage", onChange);
  return () => window.removeEventListener("storage", onChange);
}
 
export function useStoredName() {
  return useSyncExternalStore(
    subscribe,
    () => localStorage.getItem("name") ?? "guest", // browser
    () => "guest" // server and hydration: never read storage here
  );
}

The native storage event fires for changes in other tabs. If you write in the same tab, notify your subscribers too.

Fix a React custom localStorage hook hydration error

Check custom useLocalStorage hooks for a storage read in the useState initializer. That can break as soon as the component is server-rendered:

use-local-storage.ts
import { useEffect, useState } from "react";
 
export function useLocalStorage<T>(key: string, initialValue: T) {
  // Before: useState(() => JSON.parse(localStorage.getItem(key) ?? "null") ?? initialValue)
  const [value, setValue] = useState<T>(initialValue);
 
  useEffect(() => {
    const stored = localStorage.getItem(key);
    if (stored !== null) setValue(JSON.parse(stored) as T);
  }, [key]);
 
  const update = (next: T) => {
    setValue(next);
    localStorage.setItem(key, JSON.stringify(next));
  };
 
  return [value, update] as const;
}

Some library hooks let you delay the first storage read. For example, usehooks-ts supports { initializeWithValue: false }. Persisted stores need the same care; see Zustand persist.

If the correct value must appear in the server HTML, keep it in a cookie the server can read. See the theme guide for an example.

Catch it with ESLint

no-storage-in-initial-render flags storage reads during render, including guarded reads and state initializers. require-stable-server-snapshot checks the server snapshot callback.

npm install -D eslint-plugin-hydration-proof

Catch it in CI

The default test browser has no saved values. Add a scenario that represents a returning user:

hydration-proof.config.ts
import { defineConfig } from "hydration-proof";
 
export default defineConfig({
  scenarios: [
    { name: "default" },
    { name: "returning", localStorage: { name: "Sohail", theme: "dark" } },
  ],
});

The scenario writes storage before the page scripts run. With --probe, the check reloads without those saved values. If the finding disappears, storage is the proven cause. See scenarios and probes.

npx hydration-proof test --probe