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 a component reads localStorage or sessionStorage while it renders. The server has no storage and renders the default; the browser renders the stored value during hydration, so the two differ. Render a neutral value first and read storage in useEffect, or subscribe with a server snapshot.

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"

It only happens for visitors who have something stored, so it never shows in a fresh browser and always shows for returning users.

hydration-proof reports text as HP1001 and a stored class or attribute as HP1004 or HP1002, with the cause localStorage / sessionStorage read during render. In the package's test suite it names the cause with 94% confidence.

Why a localStorage hydration error happens

Storage exists only in the browser. Code that reads it during render either crashes on the server or, behind a typeof window check, renders the default there:

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 stops the crash but not the mismatch: the server renders "guest", the first client render reads "Sohail". State initializers have the same problem, because useState(() => localStorage.getItem("theme")) runs during the hydration render too.

A text difference makes React discard the server HTML and render again. An attribute difference is worse: React 19 keeps the server's class="light" without a warning in production, so the page stays in the wrong state until something re-renders it.

How to fix it

Render a neutral value, then read storage in useEffect

Render the default on both sides, and load the stored value after hydration:

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 renders the server snapshot during server rendering and hydration, then switches to the real value. It also re-renders when the value changes:

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 storage event only fires for changes made in other tabs. If the same tab writes the value, dispatch an event after writing so subscribers update.

Fix a React custom localStorage hook hydration error

Most useLocalStorage hooks read storage in the useState initializer. That works in a client-only app and breaks the moment 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;
}

Library hooks usually have a switch for this. In usehooks-ts, pass { initializeWithValue: false } to useLocalStorage for server-rendered pages, as its documentation recommends. Persisted stores have the same problem: see Zustand's persist middleware.

If the first paint must already show the stored value (a theme, a currency, a dismissed banner), keep it in a cookie. The server reads the cookie and renders the right value, so there is nothing to fix up after hydration. The theme guide shows this for dark mode.

Catch it with ESLint

no-storage-in-initial-render reports every read of localStorage or sessionStorage in render code, including state initializers and reads behind a typeof window check. require-stable-server-snapshot reports storage reads inside a getServerSnapshot.

npm install -D eslint-plugin-hydration-proof

Catch it in CI

hydration-proof tests a clean browser by default, which hides this bug the same way a fresh browser does. Give a scenario the storage your returning users have:

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

The entries are written before any page script runs. Findings are HP1001, or HP1002 and HP1004 for attributes. With --probe, hydration-proof reloads the page without the scenario's localStorage, sessionStorage and storageState; if the finding disappears, storage is the proven cause. See scenarios and probes.

npx hydration-proof test --probe