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:
"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:
"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:
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:
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.
Store it in a cookie when the server needs it
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-proofCatch it in CI
The default test browser has no saved values. Add a scenario that represents a returning user:
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