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:
"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:
"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:
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:
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.
Store it in a cookie when the server needs it
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-proofCatch 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:
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