useAuthStore.persist undefined during SSR — the RequireAuth hydration bug
Date: 2026-05-09
Affected files: src/components/RequireAuth/index.tsx
Stack: Next.js 16 (App Router, Turbopack) · React 19 · zustand 5.0.13 with persist middleware
Summary
The RequireAuth client-side auth gate crashed on first render with:
TypeError: Cannot read properties of undefined (reading 'hasHydrated')
at <unknown> (src/components/RequireAuth/index.tsx:16:26)
The cause was a lazy useState initializer that touched useAuthStore.persist.hasHydrated() during the server pre-render of a 'use client' component, at a moment when the persist middleware's .persist namespace wasn't attached to the store. Fix: defer persist-API access into useEffect, which runs only after the component mounts on the client.
Symptom
After signing in and visiting any gated route under (account)/ (e.g. /account, /orders), the dev server printed:
⨯ TypeError: Cannot read properties of undefined (reading 'hasHydrated')
at <unknown> (src/components/RequireAuth/index.tsx:16:26)
at RequireAuth (src/components/RequireAuth/index.tsx:15:43)
14 | const router = useRouter()
15 | const [hydrated, setHydrated] = useState<boolean>(() =>
> 16 | useAuthStore.persist.hasHydrated(),
| ^
17 | )
digest: '568495462'
The ⨯ glyph and the absence of a browser stack trace were the giveaway: this was a server-render error, not a client error. Next.js was throwing while building the initial HTML for the page.
Root cause
Three things had to coincide:
-
'use client'components still render on the server in Next.js App Router.'use client'is a boundary directive — it tells Next where to split the bundle for hydration. The component itself still executes during the server pre-render that produces the initial HTML. Anything that runs synchronously in the component body (includinguseStatelazy initializers) runs on the server. -
Lazy
useStateinitializers run during render.useState(() => …)calls the function immediately during the first render — both server and client. -
useAuthStore.persistwasundefinedduring the server pass. Although the store is created with thepersistmiddleware:…the middleware only attaches itsexport const useAuthStore = create<AuthState>()( persist((set) => ({ ... }), { name: 'auth-storage' }), ).persistnamespace (hasHydrated,onFinishHydration,rehydrate, etc.) once the storage backend is reachable. On the server there's nolocalStorage, and in this combination of Next 16 + zustand 5.0.13 + Turbopack the namespace was simply absent during the server pre-render. SouseAuthStore.persistwasundefined, and reading.hasHydratedon it threw.
The original code:
const [hydrated, setHydrated] = useState<boolean>(() =>
useAuthStore.persist.hasHydrated(), // ← runs during render → server crash
)
useEffect(() => {
if (useAuthStore.persist.hasHydrated()) { setHydrated(true); return }
const unsub = useAuthStore.persist.onFinishHydration(() => setHydrated(true))
return unsub
}, [])
The useEffect block was correct — effects don't run on the server. The bug was the synchronous use of useAuthStore.persist in the useState lazy initializer.
Fix
Defer every read of useAuthStore.persist into a useEffect. The component now starts with hydrated: false on both server and client, and the post-mount effect flips it to true once the persist API is reachable.
'use client'
export const RequireAuth: React.FC<Props> = ({ children }) => {
const user = useAuthStore((s) => s.user)
const router = useRouter()
// Same value on server and client → no SSR/CSR mismatch warning.
const [hydrated, setHydrated] = useState(false)
useEffect(() => {
const persistApi = useAuthStore.persist
if (!persistApi) {
// No persist middleware (or not yet attached) — treat as ready.
setHydrated(true)
return
}
if (persistApi.hasHydrated()) {
setHydrated(true)
return
}
const unsub = persistApi.onFinishHydration(() => setHydrated(true))
return unsub
}, [])
useEffect(() => {
if (hydrated && !user) router.replace('/sign-in')
}, [hydrated, user, router])
if (!hydrated || !user) {
return (
<div className="flex min-h-[40vh] items-center justify-center">
<CircularProgress aria-label="Loading" />
</div>
)
}
return <>{children}</>
}
Why this is also correct on the client
Zustand's persist middleware (with the default localStorage backend) hydrates synchronously during module evaluation. By the time React schedules useEffect, persistApi.hasHydrated() is already true, so the effect flips hydrated immediately on first commit. There's no perceptible delay.
If a future change switches to an async storage backend (IndexedDB, server-side cache), the onFinishHydration subscription handles it without further changes.
Defensive if (!persistApi) branch
Even though we know persistApi exists in production builds, treating it as optional means a subsequent refactor that drops the persist middleware (e.g. moves the token to an httpOnly cookie) won't silently break the gate — RequireAuth will just fall through to "ready" and proceed with the auth check.
Lessons
-
Anything synchronous in a
'use client'component body runs on the server during pre-render. That includesuseStatelazy initializers,useReducerinitializers, top-of-componentuseMemo, and module-level side effects. Treat them like SSR-safe code. -
useEffectis the SSR escape hatch. Anything that touches a browser-only API (localStorage,window,document) — or anything that depends on libraries that have an SSR/CSR split — should be deferred intouseEffect. -
'use client'≠ "client-only". It marks the hydration boundary, not where the code runs. To get true client-only behavior, wrap with auseEffect-set boolean (the pattern above), or usedynamic(() => import(...), { ssr: false })for an entire component tree. -
Persisted-store hydration deserves a dedicated "is hydrated" flag in any code that gates rendering on user state. Without it, server-rendered HTML shows the logged-out state and the client flashes the logged-in state on rehydration (FOUC), or vice versa. The
hydratedflag here doubles as both the "store ready" signal and the "don't render yet" signal. -
Stack-trace shape is the SSR tell.
⨯in the dev terminal + a server file path + no browser console error usually means the error was thrown during pre-render, not during client interaction.
How we got here (timeline)
The bug was introduced during the route-group rework ((account)/layout.tsx wrapping all four account routes with <RequireAuth>). Before that, RequireAuth was called from individual page components, but those <Suspense>-wrapped pages happened to defer the render in a way that hid the SSR crash. Putting the gate in a layout meant it ran during the first server pre-render of every account-section URL, which surfaced the issue.
Verifying the fix
Manual:
- With a signed-in user, visit
/account,/orders,/wishlist,/addressesand/account/change-password— no 500 in the dev terminal, no React error overlay. - Sign out, visit
/accountdirectly — brief spinner, thenrouter.replace('/sign-in')redirects without server error. - Hard-refresh
/accountwhile signed in — spinner, then content. No flash of the unauthenticated UI.
Automated (worth adding):
- A Vitest test for
RequireAuththat mocksuseAuthStore.persistasundefinedand asserts the component renders the spinner without throwing. - A second test where
persist.hasHydrated()returnsfalseinitially, thenonFinishHydrationis fired, asserting the component transitions from spinner → children.
Related code
src/components/RequireAuth/index.tsx— the gate.src/stores/useAuthStore.ts— store +persist({ name: 'auth-storage' }).src/app/(account)/layout.tsx— route-group layout that wraps every account route with<RequireAuth>.
Related external references
- Next.js: "Use Client" Directive
- Zustand: persist middleware —
hasHydrated/onFinishHydration