Zustand & Jotai RSC Initial Value Injection Pattern in Next.js App Router — A Design That Clearly Separates Server State from Client State
When migrating projects to Next.js App Router, the most common question I received was "So where do we use Zustand?" When I first heard this, I thought, "Can't we just fetch inside useEffect?" But if you re-fetch on the client data that was already fetched on the server, you create a waterfall, and you end up wastefully pulling server-side work into the bundle. Pushing everything into a global store the old way causes even stranger problems — hydration errors, data from one user leaking into another's session, and unnecessarily bloated client bundles.
This article is aimed at intermediate frontend developers who are new to Next.js App Router or are in the middle of migrating from Pages Router. If you're comfortable with basic TypeScript syntax and React hooks, you should be able to follow along. The core idea is to understand the one-way flow: fetch initial values on the server, pass them down as props, and keep the client store holding only browser interaction state. We'll walk through concrete patterns for both Zustand and Jotai with real-world code, and honestly address the common pitfalls as well.
Core Concepts
State Has an Address — Server State vs. Client State
The first thing to clarify in an RSC architecture is "where does this value originate?"
| Category | Characteristics | Examples | Responsibility |
|---|---|---|---|
| Server state | Determined on the server: DB queries, auth info, initial lists | Product list, user profile | RSC fetch → props |
| Client state | Generated from browser interactions | Modal open, filter selection, input form | Zustand · Jotai |
Server Components can't use hooks or context. There's no way to directly touch the store. So the flow naturally converges to one path:
Server (RSC): fetch data
↓ pass as serializable props
Client Component: initialize store
↓
UI Components: subscribe to store · manage interaction stateWhy a Global Singleton Store Is Dangerous
If you create a store with create() at the top level of a module — as was common before Next.js App Router — multiple requests will share the same store instance during server-side rendering. This can cause a fairly serious data leak where User A's shopping cart becomes visible to User B.
// ❌ Don't do this — state is shared across requests on the server
const useProductStore = create<ProductState>((set) => ({
products: [],
// ...
}))The solution is to create a new store instance per request and supply it via React Context. It looks cumbersome at first glance, but once you set it up as a team template, adding new stores afterward becomes as simple as copying a file.
Practical Application
Here's a quick guide on which example to read. If you have a lot of global state and are actively using middleware (immer, persist, devtools), Example 1 (Zustand) may be a better fit. If you need atom-level subscriptions with concerns separated per component, Example 2 (Jotai) might suit you better. For teams mixing both libraries, the underlying principles are the same for either pattern, so it's worth reading both examples.
Example 1: Zustand — StoreProvider Pattern
This is the most commonly used structure. You create a factory function with createStore (a store creation function that operates independently without React — used in combination with Context for SSR request isolation), and a client component called StoreProvider receives the server props and initializes the store.
Step 1. Write the vanilla store factory function
// store/product-store.ts
import { createStore } from 'zustand'
export interface Product {
id: string
name: string
price: number
}
export interface ProductState {
products: Product[] // Initial value injected from server
selectedId: string | null // Client interaction state
setSelectedId: (id: string) => void
}
export type ProductStore = ReturnType<typeof createProductStore>
export function createProductStore(initialProducts: Product[]) {
return createStore<ProductState>()((set) => ({
products: initialProducts,
selectedId: null,
setSelectedId: (id) => set({ selectedId: id }),
}))
}Step 2. Write the StoreProvider client component
// components/product-store-provider.tsx
'use client'
import { createContext, useContext, useRef } from 'react'
import { useStore } from 'zustand'
import {
createProductStore,
ProductStore,
ProductState,
Product,
} from '@/store/product-store'
const ProductStoreContext = createContext<ProductStore | null>(null)
export function ProductStoreProvider({
children,
initialProducts,
}: {
children: React.ReactNode
initialProducts: Product[]
}) {
// useState and useMemo can trigger re-renders or recompute, making them unsuitable for holding a store.
// useRef is a mutable container that stably holds a value independent of renders — a perfect fit here.
const storeRef = useRef<ProductStore | null>(null)
if (!storeRef.current) {
storeRef.current = createProductStore(initialProducts)
}
return (
<ProductStoreContext.Provider value={storeRef.current}>
{children}
</ProductStoreContext.Provider>
)
}
export function useProductStore<T>(selector: (state: ProductState) => T): T {
const store = useContext(ProductStoreContext)
if (!store) throw new Error('ProductStoreProvider is missing')
return useStore(store, selector)
}Step 3. Assemble in the Server Component
// app/products/page.tsx ← Server Component (default)
import { notFound } from 'next/navigation'
import { fetchProducts } from '@/lib/api'
import { ProductStoreProvider } from '@/components/product-store-provider'
import { ProductList } from '@/components/product-list'
export default async function ProductsPage() {
let products
try {
products = await fetchProducts() // Direct DB query, auth cookie access available
} catch {
notFound()
}
return (
<ProductStoreProvider initialProducts={products}>
<ProductList />
</ProductStoreProvider>
)
}Step 4. Use the store in a UI component
// components/product-list.tsx
'use client'
import { useProductStore } from '@/components/product-store-provider'
export function ProductList() {
const products = useProductStore((s) => s.products)
const selectedId = useProductStore((s) => s.selectedId)
const setSelectedId = useProductStore((s) => s.setSelectedId)
return (
<ul>
{products.map((p) => (
<li
key={p.id}
onClick={() => setSelectedId(p.id)}
style={{ fontWeight: selectedId === p.id ? 'bold' : 'normal' }}
>
{p.name}
</li>
))}
</ul>
)
}| Point | Description |
|---|---|
createStore vs create |
Store creation function that operates independently without React — used with Context for SSR request isolation |
Holding the store with useRef |
Protects the store from being recreated when the parent re-renders. Unlike useState or useMemo, it doesn't affect the render cycle |
initialProducts props |
Receives JSON-serialized server data and injects it as the store's initial value |
When I introduced this pattern to the team, the most common question was "Why not hold the store in useState?" The answer is that useState triggers re-renders and the initializer function can run again, making it unsuitable for holding a store. This is exactly why useRef is the right choice here.
Example 2: Jotai — useHydrateAtoms Pattern
The API is more concise than Zustand. You can inject server values as atom initial values using the useHydrateAtoms hook. One important thing to note: this hook synchronously overwrites the atom value during render. Using useEffect would execute after mount, potentially causing a mismatch between the SSR HTML and the client's initial render — useHydrateAtoms cuts off that problem at the render stage.
Step 1. Define atoms
Exporting types together from the atoms file prevents duplicate definitions in other components.
// atoms/product-atoms.ts
import { atom } from 'jotai'
export interface Product {
id: string
name: string
price: number
}
export const productsAtom = atom<Product[]>([]) // For server initial value
export const selectedIdAtom = atom<string | null>(null) // Client interactionStep 2. Write the hydration wrapper component
// components/hydrate-products.tsx
'use client'
import { useHydrateAtoms } from 'jotai/utils'
import { productsAtom, Product } from '@/atoms/product-atoms'
export function HydrateProducts({
products,
children,
}: {
products: Product[]
children: React.ReactNode
}) {
// Synchronously sets the atom's initial value during render (safer than useEffect)
useHydrateAtoms([[productsAtom, products]])
return <>{children}</>
}Step 3. Assemble in the Server Component
// app/products/page.tsx
import { notFound } from 'next/navigation'
import { fetchProducts } from '@/lib/api'
import { HydrateProducts } from '@/components/hydrate-products'
import { ProductList } from '@/components/product-list'
export default async function ProductsPage() {
let products
try {
products = await fetchProducts()
} catch {
notFound()
}
return (
<HydrateProducts products={products}>
<ProductList />
</HydrateProducts>
)
}When first introducing Jotai to a team project, a common question was "What does it mean that an atom is only initialized once?" More precisely, it means once per Provider instance. If the Provider remounts (e.g., when a page component remounts due to route navigation), initialization happens again. The practical limitation is that server data changes within a single mount lifecycle are not automatically reflected.
Serialization: Props passed from RSC to the client must be JSON-representable values.
Date,Map,Set, functions, and class instances cannot be passed as-is. It's standard practice to convertDateto an ISO string andMapto an array before passing them.
Example 3: jotai-ssr — Declaring Directly in a Server Component
This is a simplified version of the Jotai pattern above. Using the jotai-ssr package, you can declare initial values directly inside a Server Component without even needing a hydration wrapper component. We reviewed this internally, and the code is noticeably cleaner. However, since it's still a pattern under active community discussion, it's worth being cautious before adopting it in production.
// app/products/page.tsx (Server Component)
import { HydrationBoundary } from 'jotai-ssr'
import { notFound } from 'next/navigation'
import { fetchProducts } from '@/lib/api'
import { productsAtom } from '@/atoms/product-atoms'
import { ProductList } from '@/components/product-list'
export default async function ProductsPage() {
let products
try {
products = await fetchProducts()
} catch {
notFound()
}
return (
// Atom initial values can be declared directly inside the Server Component
<HydrationBoundary hydrateAtoms={[[productsAtom, products]]}>
<ProductList />
</HydrationBoundary>
)
}
useHydrateAtomsvsjotai-ssr: The former initializes via a hook inside a client component; the latter declares a boundary from a server component. For stable production use right now, theuseHydrateAtomspattern is more battle-tested.
Pros and Cons Analysis
Advantages
| Item | Description |
|---|---|
| Separation of concerns | Server: data fetching / Client: UI state — clear roles make debugging easier |
| Bundle size reduction | No need to duplicate server data in the store, keeping the client bundle lighter |
| SSR safety | The createStore + Context pattern fundamentally prevents state leaks between requests |
| DevTools integration | Zustand DevTools middleware works as-is, making state debugging convenient |
| Jotai option: atomic subscriptions | Atom-level subscriptions minimize unnecessary re-renders |
Drawbacks and Caveats
| Item | Description | Mitigation |
|---|---|---|
| Boilerplate | StoreProvider and Context setup add overhead |
Creating a team template once reduces subsequent store additions to simple file copying |
| Serialization constraints | Date, Map, Set, etc. can't be passed as props directly |
Convert to ISO strings, arrays, etc. before passing |
| Jotai single initialization | Atoms are initialized only once per Provider instance — server value changes within a single mount don't auto-propagate | Trigger remount on route navigation or set up a separate update logic |
| Hydration mismatch | Different server/client initial values cause hydration errors | Always initialize the store with the exact same value passed down from the server |
Hydration Error: An error that occurs when the HTML rendered on the server differs from what React redraws on the client. It commonly appears when values that change depending on execution time — like
Math.random()orDate.now()— are placed in the initial state.
⚠️ The 3 Most Common Mistakes in Production
-
Creating a global store with
create()at the top level of a module — In App Router, state can be shared across requests during server-side execution. Using thecreateStore+ Context pattern is recommended. -
Re-storing in Zustand/Jotai data that RSC already fetched — If TanStack Query is handling server data caching, duplicating the same data in a client store creates synchronization issues between the two sources. Keep the roles clearly separated.
-
Passing
Date,Map,Setas props directly — Props passed from RSC to client components must be JSON-serializable. Convertnew Date()to.toISOString()andMaptoArray.from(map.entries())before passing.
Closing Thoughts
The core of state management in the server component era lies in this principle: "Values decided by the server go as props; only values created by the browser go into the store." Whether you use Zustand or Jotai, following this principle significantly reduces hydration errors, data leaks, and unnecessary client bundle bloat.
For teams mixing both libraries, here's a simple rule of thumb: Use Zustand for complex global state that needs middleware or DevTools, and Jotai for independent atomic state scoped to component boundaries — the roles naturally fall into place.
Three steps you can start right now:
-
Open your current project's global store and categorize which values are "decided on the server." If you see values like
products,user, orinitialConfig, those are candidates for RSC fetch + props injection. -
If you're using Zustand, replace
create()withcreateStore()to build a factory function, copy theProductStoreProviderpattern above, and set it up as a team template — subsequent store additions will noticeably speed up. -
If you're using Jotai, create a
HydrateProductswrapper pattern usinguseHydrateAtoms, then gradually apply it to atoms that need server initial value injection.
Next article: The pattern of using TanStack Query and Zustand together — implementing optimistic updates by completely separating server data caching from UI state
References
- Zustand Official Docs — Setup with Next.js
- Jotai Official Docs — SSR Utilities (useHydrateAtoms)
- Jotai Official Docs — Next.js Guide
- jotai-ssr npm package
- Jotai GitHub Discussion #2692 — New RSC Support Utility
- Jotai GitHub Discussion #2470 — Full App Router useHydrateAtoms Example
- Zustand GitHub Discussion #2200 — Using Zustand in RSC (misuse patterns)
- Zustand GitHub Discussion #2772 — Global vs Scoped Store in Next.js 14
- TkDodo — Zustand and React Context
- Next.js Official Docs — Server and Client Components