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:
- The server has no
localStorage, so it renders the default:bears: 0. - The browser creates the store and loads the saved value:
bears: 7. - React sees
7on its first browser render, but the server HTML contains0.
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 renderingReact 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:
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,
}
)
);"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:
"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;
}"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:
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