Hydration Proof

Search documentation

Find a page or section

Fix the Zustand persist hydration error

The server renders the default state, the browser renders the saved one.

A Zustand hydration error happens when a store that uses the persist middleware loads saved state from localStorage before React hydrates. The server has no storage and renders the default state; the browser's first render already shows the saved state. Delay loading the saved state until after hydration.

Why the Zustand hydration error happens

persist hydrates the store from storage. Zustand's docs explain that with a synchronous storage such as localStorage, "the Zustand store will already have been hydrated at its creation" (persisting store data). In a server-rendered app, that means:

  1. On the server, there is no localStorage, so the store keeps its initial state: bears: 0.
  2. In the browser, the store is created, reads storage and holds bears: 7 before React renders anything.
  3. During hydration, the component renders 7 where the server HTML says 0.

The Zustand docs list the messages you get in Next.js:

Text content does not match server-rendered HTML
Hydration failed because the initial UI does not match what was rendered on the server
There was an error while hydrating. Because the error happened outside of a Suspense boundary, the entire root will switch to client rendering

React 19 reports the same bug as "Hydration failed because the server rendered text didn't match the client". A store value used in a className or style is worse: React 19 production builds keep the server's attribute without any error.

Fix the Zustand Next.js hydration error

All three fixes make the first client render use the same state as the server, then switch to the saved state.

Skip hydration and rehydrate after mount

Zustand skip hydration (skipHydration: true) stops the store from reading storage when it is created. You call rehydrate() yourself, in an effect, after React has hydrated the page:

stores/bear-store.ts
import { create } from "zustand";
import { persist } from "zustand/middleware";
 
type BearState = {
  bears: number;
  addBear: () => void;
};
 
export const useBearStore = create<BearState>()(
  persist(
    (set) => ({
      bears: 0,
      addBear: () => set((state) => ({ bears: state.bears + 1 })),
    }),
    {
      name: "bear-storage",
      skipHydration: true,
    }
  )
);
components/store-hydration.tsx
"use client";
 
import { useEffect } from "react";
import { useBearStore } from "@/stores/bear-store";
 
export function StoreHydration() {
  useEffect(() => {
    void useBearStore.persist.rehydrate();
  }, []);
  return null;
}

Render <StoreHydration /> once, near the root, for example in app/layout.tsx. Every component reads the default state during hydration and re-renders with the saved state right after.

Wait for hydration before showing stored values

If showing the default for a moment is wrong (a logged-out state for a logged-in user), render a placeholder until the store has hydrated. The Zustand docs build a hook from onFinishHydration and hasHydrated:

hooks/use-store-hydrated.ts
"use client";
 
import { useEffect, useState } from "react";
import { useBearStore } from "@/stores/bear-store";
 
export function useStoreHydrated() {
  const [hydrated, setHydrated] = useState(false);
 
  useEffect(() => {
    const unsubHydrate = useBearStore.persist.onHydrate(() =>
      setHydrated(false)
    );
    const unsubFinish = useBearStore.persist.onFinishHydration(() =>
      setHydrated(true)
    );
    setHydrated(useBearStore.persist.hasHydrated());
    return () => {
      unsubHydrate();
      unsubFinish();
    };
  }, []);
 
  return hydrated;
}
components/bear-count.tsx
"use client";
 
import { useBearStore } from "@/stores/bear-store";
import { useStoreHydrated } from "@/hooks/use-store-hydrated";
 
export function BearCount() {
  const bears = useBearStore((state) => state.bears);
  const hydrated = useStoreHydrated();
  return <span>{hydrated ? bears : "–"}</span>;
}

hydrated starts as false on the server and during hydration, so both render the placeholder.

Read the store through a delayed hook

The Zustand docs' Next.js section suggests a small useStore wrapper that returns undefined on the first render and the store's value after an effect. It needs no change to the store, but every component has to read the store through it and handle undefined.

Should the server know the value?

Every fix above shows the default first. When the value matters for the first paint (a theme, a cart count, a language), keep it where the server can read it, such as a cookie, and pass it to the page as a prop. Then the server renders the real value and nothing changes after hydration. The storage cause guide covers the cookie approach.

Find the pages it breaks

The ESLint plugin reads one file at a time, so it cannot see what persist loads from storage. hydration-proof test loads the page in a real browser instead. Give a scenario the saved state your users have, so the store has something to load:

hydration-proof.config.ts
import { defineConfig } from "hydration-proof";
 
export default defineConfig({
  scenarios: [
    { name: "default" },
    {
      name: "returning-user",
      localStorage: {
        "bear-storage": JSON.stringify({ state: { bears: 7 }, version: 0 }),
      },
    },
  ],
});

A mismatch is reported as HP1001 for text or HP1004 for a class name, with the likely cause "localStorage / sessionStorage read during render". With --probe, the page is loaded again without the scenario's storage; if the value changes, the cause is proven:

npx hydration-proof test --probe