The Real Reason Redux·Zustand Hydration Errors Blow Up in Next.js App Router, and SSR Serialization Solution Patterns
When moving to the Next.js App Router, this was the very first problem I ran into on the state management side. The server had clearly fetched the data just fine, yet the screen was flickering and the console was flooded with red Hydration Errors. I spent quite a while wondering, "The server and client have the same value — why is there an error?" I stepped on this trap especially often while migrating a Pages Router project to App Router, but once you understand it, the cause turns out to be simpler than you'd think.
This post is aimed at frontend developers who are already using the Next.js App Router or are in the middle of migrating to it, and who have used TypeScript with Redux or Zustand at least once. The most common causes of SSR state management bugs are two things: not explicitly handling the Serialization Boundary, and using a singleton store without per-request isolation. By the end of this post, you'll have a concrete checklist of exactly where and how to change your Redux·Zustand code.
First, it's worth pausing to understand why the browser and server behave so differently. The browser keeps state in memory, but the server renders in a fresh memory context per request and then discards it. To pass state from server → client, it must go through JSON — and the problem starts when information is silently lost during that JSON conversion. In the Pages Router era, getServerSideProps made this boundary relatively explicit, but in App Router the boundary between Server Components and Client Components is implicit, making it easier to make mistakes.
Core Concepts
What Is a Serialization Boundary?
Simplifying the SSR flow looks like this:
Server state → JSON.stringify() → Embedded in HTML → Sent over network
→ JSON.parse() → Client store restoredJavaScript state created on the server is converted to JSON and embedded in the HTML, then client-side JavaScript executes and reads that JSON to restore the store. This process is called Hydration, and the point where JSON conversion occurs is the Serialization Boundary.
The core of the problem:
JSON.stringify()cannot accurately representDate,Map,Set,BigInt, functions, or class instances. Serializingnew Date('2024-01-15')yields the string"2024-01-15T00:00:00.000Z", and when deserialized it comes back as a string, not aDateobject.
You also need to be careful when converting BigInt to Number. It's safe for small values like Number(1500n), but converting a BigInt exceeding Number.MAX_SAFE_INTEGER (2⁵³−1) to Number truncates precision. If you're dealing with large IDs or monetary amounts, using String() instead of Number() is the safe choice.
Why Do Hydration Errors Occur?
When React runs on the client, it builds a new virtual DOM and compares it against the HTML delivered by the server (Reconciliation). If the two results differ even slightly, it throws a Hydration Error. If the server rendered count: 0 but the client initialized with count: 5 from localStorage, the 0 in the server HTML and the 5 from the client render will be mismatched, causing an inconsistency.
The Danger of Singleton Stores
There is a trap that's often overlooked in SSR environments. A Node.js server handles many requests concurrently, but modules are only loaded once. If you create your store as a global singleton, state changed during User A's request can be exposed directly to User B.
// ❌ Dangerous pattern — all requests share the same store
const store = createStore(reducer)
export default store// ✅ Safe pattern — new store created per request
export const makeStore = () => createStore(reducer)This difference is the essence of per-request isolation.
Why the 'use client' Directive Is Required on StoreProvider
In App Router, every component is a React Server Component (RSC) by default. RSCs run only on the server and cannot use React hooks like useState, useRef, or useContext. StoreProvider must initialize the store with useRef and provide it to the subtree via React Context, so it must be a Client Component. Adding 'use client' at the top of the file is that declaration.
Now let's look at how these concepts manifest in actual code.
Practical Application
Example 1: Redux — Request Isolation and Initial State Passing with the makeStore Factory Pattern
I suffered quite a bit in a Pages Router → App Router migration project because I missed this pattern. In the original store.ts file, there was a single line const store = makeStore() at the top of the module — and that was being shared across all requests. The Redux Toolkit official docs recommend abandoning this singleton pattern entirely in App Router and using the factory function pattern instead.
// lib/store.ts
import { configureStore } from '@reduxjs/toolkit'
import counterReducer from './counterSlice'
export const makeStore = (preloadedState?: Partial<RootState>) =>
configureStore({
reducer: {
counter: counterReducer,
},
preloadedState,
})
export type AppStore = ReturnType<typeof makeStore>
export type RootState = ReturnType<AppStore['getState']>
export type AppDispatch = AppStore['dispatch']StoreProvider receives the initial state serialized on the server and passes it to makeStore. The reason for initializing with useRef is to prevent a new store from being created every time the Client Component re-renders.
// components/StoreProvider.tsx
'use client'
import { useRef } from 'react'
import { Provider } from 'react-redux'
import { makeStore, AppStore, RootState } from '@/lib/store'
export function StoreProvider({
initialState,
children,
}: {
initialState?: Partial<RootState>
children: React.ReactNode
}) {
const storeRef = useRef<AppStore | null>(null)
if (!storeRef.current) {
storeRef.current = makeStore(initialState)
}
return <Provider store={storeRef.current}>{children}</Provider>
}Once the full flow of serializing data in a Server Component and passing it as initial state to StoreProvider is in place, it looks like this:
// app/page.tsx (Server Component)
import { StoreProvider } from '@/components/StoreProvider'
async function fetchUserData() {
return {
name: '홍길동',
createdAt: new Date('2024-01-15'),
score: 9007199254740991n, // BigInt close to Number.MAX_SAFE_INTEGER
}
}
export default async function Page() {
const data = await fetchUserData()
const serialized = {
counter: {
name: data.name,
createdAt: data.createdAt.toISOString(), // Date → ISO string
score: String(data.score), // Large BigInt → string (precision guaranteed)
},
}
return (
<StoreProvider initialState={serialized}>
<ClientPage />
</StoreProvider>
)
}| Code Point | Description |
|---|---|
makeStore(preloadedState) |
Creates an independent store per request, injects server initial state |
useRef initialization |
Prevents duplicate store creation on client re-renders |
toISOString() |
Safe passage of Date across the serialization boundary |
String(bigIntValue) |
Prevents precision loss when exceeding MAX_SAFE_INTEGER |
Example 2: Zustand — StoreProvider + skipHydration Pattern
Zustand has less boilerplate than Redux, which also makes it easier to make mistakes with SSR. To be honest, I've had the experience of attaching the persist middleware without skipHydration and suffering through Hydration Mismatch for quite a while. The server rendered with count: 0, but persist initialized with count: 5 from localStorage — the mismatch was inevitable.
If Example 1 (Redux) is well-suited for large-scale apps or cases where DevTools time-travel debugging matters, Example 2 (Zustand) is a better fit for projects where bundle size is important or you want to minimize boilerplate.
// stores/counterStore.ts
import { createStore } from 'zustand'
import { persist } from 'zustand/middleware'
interface CounterState {
count: number
increment: () => void
}
export const createCounterStore = (initCount = 0) =>
createStore<CounterState>()(
persist(
(set) => ({
count: initCount,
increment: () => set((s) => ({ count: s.count + 1 })),
}),
{
name: 'counter-storage',
skipHydration: true, // Blocks localStorage access on the server
}
)
)
export type CounterStore = ReturnType<typeof createCounterStore>Using createStore() instead of create() is important. create() creates a module-level singleton hook where state is shared across requests in SSR, whereas createStore() returns a pure store instance that can be isolated via Context.
// components/CounterStoreProvider.tsx
'use client'
import { createContext, useContext, useRef, useEffect } from 'react'
import { useStore } from 'zustand'
import { createCounterStore, CounterStore } from '@/stores/counterStore'
const CounterStoreContext = createContext<CounterStore | null>(null)
export function CounterStoreProvider({
initialCount,
children,
}: {
initialCount: number
children: React.ReactNode
}) {
const storeRef = useRef<CounterStore | null>(null)
if (!storeRef.current) {
storeRef.current = createCounterStore(initialCount)
}
useEffect(() => {
// Run localStorage hydration only after client mount
storeRef.current?.persist.rehydrate()
}, [])
return (
<CounterStoreContext.Provider value={storeRef.current}>
{children}
</CounterStoreContext.Provider>
)
}
export function useCounterStore<T>(
selector: (state: ReturnType<CounterStore['getState']>) => T
) {
const store = useContext(CounterStoreContext)
if (!store) throw new Error('Must be used inside CounterStoreProvider')
return useStore(store, selector)
}Using ReturnType<CounterStore['getState']> for the selector type gives you the same result without complex conditional types like CounterStore extends { getState: () => infer S } ? S : never.
Example 3: Blocking Hydration Mismatch at the Source with useSyncExternalStore
For cases where skipHydration is still not enough, there is the option of using useSyncExternalStore directly. From my experience using it, it lets you express the server/client initial value mismatch explicitly at the code level, which makes it much easier to trace the cause of Hydration Errors.
// hooks/useCountOnClient.ts
import { useSyncExternalStore } from 'react'
import { CounterStore } from '@/stores/counterStore'
export function useCountOnClient(store: CounterStore) {
return useSyncExternalStore(
store.subscribe,
() => store.getState().count, // Value read on the client
() => 0 // Fallback for server SSR + client hydration phase
)
}
useSyncExternalStorethird argumentgetServerSnapshot: This value is used on both the server-rendering side and during the client's hydration phase. Only after hydration completes does it switch to the second argumentgetSnapshotfor subsequent re-renders. In other words, if this value differs from the server's initial state, a Hydration Error will still occur during the hydration phase.
Pros and Cons Analysis
Library-by-Library Characteristic Comparison
| Item | Redux (RTK) | Zustand |
|---|---|---|
| SSR safety | serializabilityMiddleware detects non-serializable values at runtime |
Request isolation easily implemented with createStore factory |
| Debugging | Redux DevTools time-travel debugging | DevTools middleware support, simple setup |
| Bundle size | ~40KB+ including RTK | ~1KB (excluding middleware) |
| Boilerplate | Slice + Provider structure required | Store complete in one file |
| Server state integration | Caching/rehydration unified with RTK Query | Requires combining separate libraries |
| Serialization enforcement | serializabilityMiddleware gives runtime warnings |
Not enforced, higher degree of freedom |
Downsides and Caveats
| Item | Detail | Mitigation |
|---|---|---|
| Redux — non-serializable values | Date, Map, Set cannot be stored directly in the store |
Store ISO strings in the store, convert to Date in selectors |
| Redux — disabling middleware | Globally disabling serializabilityMiddleware collapses SSR safety |
Only exempt specific action types; never globally disable |
| Zustand — singleton risk | Using global create() causes state leakage across requests |
Use createStore factory + Context pattern |
| Zustand — persist Mismatch | Mismatch between localStorage initial value and server initial value |
skipHydration: true + call rehydrate() in useEffect |
| Common — XSS | superjson does not support HTML escaping |
Use devalue or serialize-javascript when including external input |
| Common — RSC limitation | Cannot directly read/write store in React Server Components | Pass server data as props and initialize in StoreProvider |
It's worth elaborating on the XSS risk from superjson. superjson does not HTML-escape values themselves. When the serialized JSON is inlined inside a <script> tag, if user input contains </script>, it can escape the script context and inject arbitrary HTML. If you're serializing user-submitted data or external API responses and embedding them into HTML, it's safer to use a library that supports escaping, such as devalue or serialize-javascript.
The Most Common Real-World Mistakes
-
Using
create()wherecreateStore()should be used — When building a Context-based store in Zustand, usecreateStore.create()becomes a module-level singleton, causing state to be shared across requests in SSR. -
Globally disabling
serializabilityMiddleware— In Redux, there are cases where the middleware is disabled entirely in order to storeDateobjects in the store. This leaves the entire serialization boundary unguarded. It's better to only exempt specific action types or paths, or to store only strings in the store. -
Applying SSR with the
persistmiddleware withoutskipHydration—persistreads and initializes state from the browser'slocalStorage. The server has nolocalStorage, so the server's initial value and the client's initial value will differ, causing a Hydration Error. Use the pattern of settingskipHydration: trueand manually callingrehydrate()after mount.
Closing Thoughts
There are things you can check in your codebase right now. Going through the steps below in order will let you find the cause of most Hydration Errors.
-
Check how the store is created — If you have a singleton in the form
const store = makeStore()at the top of a module, switch to the factory patternexport const makeStore = (preloadedState?) => configureStore({...})and refactor to initialize withuseRefinStoreProvider. -
Audit the serialization boundary — Check whether
Date,Map,Set,BigInt, functions, or class instances are being stored in the store. For Redux, keepserializabilityMiddlewarewarnings enabled in development; for Zustand, compare whether the SSR initial value and thelocalStoragevalue match. -
Verify with a production build after changes — Running a production build locally with
next build && next startinstead ofnext devreproduces Hydration Errors more accurately. Simply matching the third argument ofuseSyncExternalStore(getServerSnapshot) to the server's initial state will catch most Hydration Mismatches.
References
- Redux Toolkit Setup with Next.js — Official Docs
- Server Side Rendering — RTK Query Official Docs
- Serializability Middleware — Redux Toolkit Official Docs
- Setup with Next.js — Zustand Official Docs
- persist Middleware — Zustand Official Docs
- Fixing React hydration errors when using Zustand persist with useSyncExternalStore — Medium
- Hydration in Redux: Why You're Likely Doing It Wrong — Medium/Devglyph
- Why Redux Doesn't Allow Non-Serializable Data — Medium
- superjson GitHub — flightcontrolhq/superjson
- Next.js Hydration Error Official Docs
- NextJS and zustand Discussion #2326 — GitHub pmndrs/zustand