# Fix the Next.js localStorage hydration error

> A localStorage hydration error happens when render code reads localStorage or sessionStorage. Render a neutral value first, then read storage after hydration.

Source: https://hydration.jscrate.dev/docs/causes/storage
Last updated: 2026-09-18

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

```text
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](https://hydration.jscrate.dev/docs/issues/hp1001) and a stored
class or attribute as [HP1004](https://hydration.jscrate.dev/docs/issues/hp1004) or
[HP1002](https://hydration.jscrate.dev/docs/issues/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:

```tsx title="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:

```tsx title="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:

```ts title="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:

```tsx title="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](https://hydration.jscrate.dev/docs/guides/zustand-hydration-error).

### 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](https://hydration.jscrate.dev/docs/causes/theme) shows this for dark mode.

## Catch it with ESLint

[`no-storage-in-initial-render`](https://hydration.jscrate.dev/docs/rules/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`](https://hydration.jscrate.dev/docs/rules/require-stable-server-snapshot)
reports storage reads inside a `getServerSnapshot`.

```bash
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:

```ts title="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](https://hydration.jscrate.dev/docs/scenarios) and [probes](https://hydration.jscrate.dev/docs/probes).

```bash
npx hydration-proof test --probe
```

## Related

- [Zustand persist and hydration](https://hydration.jscrate.dev/docs/guides/zustand-hydration-error)
- [Dark mode and theme mismatches](https://hydration.jscrate.dev/docs/causes/theme)
- [Browser-only APIs in render](https://hydration.jscrate.dev/docs/causes/browser-api)
- [The no-storage-in-initial-render rule](https://hydration.jscrate.dev/docs/rules/no-storage-in-initial-render)
- [Missing getServerSnapshot](https://hydration.jscrate.dev/docs/errors/missing-getserversnapshot)
