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 can happen when persist loads a saved value before React hydrates. The server renders the store's default value because it has no localStorage. The browser renders the saved value instead. Make the first browser render use the default too, then load the saved value.

Why the Zustand hydration error happens

With synchronous storage such as localStorage, Zustand's persist middleware can load saved state as soon as the store is created (Zustand docs). On a server-rendered page:

  1. The server has no localStorage, so it renders the default: bears: 0.
  2. The browser creates the store and loads the saved value: bears: 7.
  3. React sees 7 on its first browser render, but the server HTML contains 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 uses different wording for a text mismatch. If the store value changes a className or style, the mismatch may be silent in production and leave the server attribute in place.

Fix the Zustand Next.js hydration error

Choose the approach that fits when you need to show the saved value. Each keeps the initial server and browser output consistent.

Zustand skip hydration: rehydrate after mount

Set skipHydration: true to keep the default state when the store is created. Then call rehydrate() in an effect after hydration:

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. Components use the default state for the first render, then update when saved state loads.

Wait for hydration before showing stored values

If showing the default value would mislead users, show a placeholder until the store loads. This hook uses Zustand's hydration events:

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 both sides, so the placeholder matches. Once the store has loaded, the component shows the saved value.

Read the store through a delayed hook

Another option is a useStore wrapper that returns undefined at first and reads the saved value after an effect. It leaves the store unchanged, but every caller must handle undefined. See the Zustand Next.js guide for the pattern.

Should the server know the value?

The approaches above show a default or placeholder first. If the real value matters immediately, store it somewhere the server can read, such as a cookie, and pass it to the page. The storage guide explains this approach.

Find the pages it breaks

To reproduce the mismatch, test with saved state. Add a scenario that sets the same storage value a returning user might have:

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 }),
      },
    },
  ],
});

The report can show HP1001 for text or HP1004 for a class name. Use --probe to run another check without the scenario's storage and see whether it caused the difference:

npx hydration-proof test --probe