Server-Side Rendering with Zustand: SSR, SSG, ISR
State management is one of the trickiest parts of building universal React applications. When your components render on the server, your state library needs to behave correctly without access to browser-only APIs like window or localStorage. Zustand, with its minimal and unopinionated design, is an excellent fit for server-rendered applications — but you need to follow a few key patterns to avoid hydration mismatches, shared state across requests, and stale data on cached pages.
This tutorial walks through using Zustand with Next.js across three rendering strategies: Server-Side Rendering (SSR), Static Site Generation (SSG), and Incremental Static Regeneration (ISR). You'll learn what each strategy means for your store, how to hydrate client state from the server, and how to avoid the most common pitfalls.
Why Zustand Works Well for SSR
Zustand stores are created with a plain function call — create() — and they don't rely on React Context by default. This means you can instantiate a store anywhere, including inside a server module or per-request scope. Unlike Redux, there's no provider boilerplate; unlike Context-based solutions, you don't need to wrap your tree in a provider to share state.
However, this flexibility comes with a responsibility: a store created at module scope is a singleton. On a Node.js server, that singleton is shared across every incoming request. If you mutate it during one request, the next request will see the leftover state. This is the single most important concept to internalize before writing SSR code with Zustand.
Setting Up the Project
Start by creating a Next.js app and installing Zustand:
npx create-next-app@latest zustand-ssr-demo
cd zustand-ssr-demo
npm install zustand
We'll use the Next.js App Router, which supports React Server Components and streaming. The patterns below also work in the Pages Router with minor adjustments.
The Core Problem: Module-Level Singletons
Consider this naive store:
// stores/cart.js
import { create } from 'zustand'
export const useCart = create((set) => ({
items: [],
addItem: (item) =>
set((state) => ({ items: [...state.items, item] })),
}))
On the client, this is fine. On the server, every request that imports this module shares the same items array. User A's cart could leak into User B's response. The fix is to create a fresh store per request and attach it to the request context, or to use a factory pattern that returns a new store instance.
Pattern 1: Per-Request Store with a Factory Function
The cleanest approach is to export a factory function instead of a singleton. Each server request creates its own store, and the client hydrates from the serialized state passed down via props.
// stores/cartStore.js
import { create } from 'zustand'
export const createCartStore = (initialState) =>
create((set) => ({
items: initialState?.items ?? [],
addItem: (item) =>
set((state) => ({ items: [...state.items, item] })),
removeItem: (id) =>
set((state) => ({
items: state.items.filter((i) => i.id !== id),
})),
}))
On the server, you call createCartStore() inside your request handler. On the client, you call it once and hydrate with the server's initial state.
SSR: Server-Side Rendering with Zustand
SSR renders the page on every request. This is ideal for personalized or frequently changing data, like a user's shopping cart or dashboard. The server fetches data, creates a store, renders the tree, and sends both the HTML and the serialized state to the client.
Server Component Fetching Data
In the App Router, server components can fetch data directly. We'll fetch cart data on the server, create a store instance, render the client component, and pass the initial state as a prop.
// app/cart/page.js
import { createCartStore } from '@/stores/cartStore'
import CartView from './CartView'
export default async function CartPage() {
// Fetch cart data on the server (per request)
const res = await fetch('https://api.example.com/cart', {
cache: 'no-store',
})
const cartData = await res.json()
// Create a per-request store instance
const store = createCartStore({
items: cartData.items,
})
// Extract initial state for client hydration
const initialState = store.getState()
return <CartView initialState={initialState} />
}
Client Component Hydrating the Store
The client component receives the initial state and creates its own store instance. Because the initial state matches what the server rendered, React's hydration will succeed without mismatches.
// app/cart/CartView.js
'use client'
import { useEffect, useRef } from 'react'
import { createCartStore } from '@/stores/cartStore'
export default function CartView({ initialState }) {
// Create the store once, hydrated with server state
const storeRef = useRef(null)
if (!storeRef.current) {
storeRef.current = createCartStore(initialState)
}
const items = storeRef.current((s) => s.items)
const addItem = storeRef.current((s) => s.addItem)
return (
<div>
<h1>Your Cart</h1>
<ul>
{items.map((item) => (
<li key={item.id}>{item.name} — ${item.price}</li>
))}
</ul>
<button onClick={() => addItem({ id: Date.now(), name: 'Widget', price: 9.99 })}>
Add Widget
</button>
</div>
)
}
The key insight: useRef ensures the store is created only once per component instance on the client. The initialState prop guarantees the client starts with the same data the server rendered.
SSG: Static Site Generation with Zustand
SSG generates HTML at build time. The same HTML is served to every user, so per-request state doesn't apply. Instead, you fetch data at build time, bake it into the store's initial state, and ship it as part of the static page.
This is perfect for content that rarely changes: blog posts, product catalogs, documentation. The store acts as a client-side cache that starts pre-populated with build-time data.
Generating a Static Product Page
// app/products/[slug]/page.js
import { createProductStore } from '@/stores/productStore'
import ProductView from './ProductView'
export async function generateStaticParams() {
const res = await fetch('https://api.example.com/products')
const products = await res.json()
return products.map((p) => ({ slug: p.slug }))
}
export default async function ProductPage({ params }) {
const res = await fetch(
`https://api.example.com/products/${params.slug}`
)
const product = await res.json()
const store = createProductStore({ product })
const initialState = store.getState()
return <ProductView initialState={initialState} />
}
The Product Store and Client Component
// stores/productStore.js
import { create } from 'zustand'
export const createProductStore = (initialState) =>
create((set) => ({
product: initialState?.product ?? null,
quantity: 1,
setQuantity: (q) => set({ quantity: q }),
selectedVariant: initialState?.product?.variants?.[0] ?? null,
selectVariant: (v) => set({ selectedVariant: v }),
}))
// app/products/[slug]/ProductView.js
'use client'
import { useRef } from 'react'
import { createProductStore } from '@/stores/productStore'
export default function ProductView({ initialState }) {
const storeRef = useRef(null)
if (!storeRef.current) {
storeRef.current = createProductStore(initialState)
}
const product = storeRef.current((s) => s.product)
const quantity = storeRef.current((s) => s.quantity)
const setQuantity = storeRef.current((s) => s.setQuantity)
const selectedVariant = storeRef.current((s) => s.selectedVariant)
const selectVariant = storeRef.current((s) => s.selectVariant)
if (!product) return <p>Loading...</p>
return (
<div>
<h1>{product.name}</h1>
<p>${product.price}</p>
<div>
{product.variants.map((v) => (
<button
key={v.id}
onClick={() => selectVariant(v)}
style={{ fontWeight: selectedVariant?.id === v.id ? 'bold' : 'normal' }}
>
{v.name}
</button>
))}
</div>
<div>
<button onClick={() => setQuantity(Math.max(1, quantity - 1))}>-</button>
<span>{quantity}</span>
<button onClick={() => setQuantity(quantity + 1)}>+</button>
</div>
</div>
)
}
Because the page is statically generated, the initial HTML includes the product data. The client store hydrates from that same data, so there's no flash of loading state and no hydration mismatch.
ISR: Incremental Static Regeneration with Zustand
ISR combines the performance of SSG with the freshness of SSR. Pages are generated statically, but Next.js revalidates them in the background at a configurable interval. When new data arrives, the static page is regenerated.
From Zustand's perspective, ISR works exactly like SSG — the store is hydrated from the initial state baked into the page. The difference is that the initial state may be updated periodically as the page regenerates.
Adding Revalidation to a Product Page
// app/products/[slug]/page.js
import { createProductStore } from '@/stores/productStore'
import ProductView from './ProductView'
export async function generateStaticParams() {
const res = await fetch('https://api.example.com/products')
const products = await res.json()
return products.map((p) => ({ slug: p.slug }))
}
// Revalidate every 60 seconds
export const revalidate = 60
export default async function ProductPage({ params }) {
const res = await fetch(
`https://api.example.com/products/${params.slug}`
)
const product = await res.json()
const store = createProductStore({ product })
const initialState = store.getState()
return <ProductView initialState={initialState} />
}
On-Demand Revalidation
You can also trigger ISR revalidation on demand using a webhook or API route. When your CMS publishes an update, it calls an endpoint that regenerates the affected pages:
// app/api/revalidate/route.js
import { revalidatePath } from 'next/cache'
export async function POST(request) {
const body = await request.json()
const { slug } = body
if (slug) {
revalidatePath(`/products/${slug}`)
} else {
revalidatePath('/products/[slug]', 'page')
}
return Response.json({ revalidated: true, slug })
}
After revalidation, the next visitor receives the freshly generated page with updated initial state. Existing clients keep their local store state until they navigate or reload — which is usually the desired behavior for ISR.
Handling Client-Side Navigation
When users navigate between pages client-side, server components re-run on the server (via RSC payloads), but client component stores persist in memory. This is generally what you want — the cart follows the user. However, if a page's store depends on route-specific data, you need to update the store when the route changes.
// app/products/[slug]/ProductView.js
'use client'
import { useEffect, useRef } from 'react'
import { createProductStore } from '@/stores/productStore'
export default function ProductView({ initialState }) {
const storeRef = useRef(null)
// Recreate store when initialState changes (new route)
if (!storeRef.current || storeRef.current.__productId !== initialState.product.id) {
storeRef.current = createProductStore(initialState)
storeRef.current.__productId = initialState.product.id
}
// ... rest of component
}
Alternatively, you can use a useEffect to sync new server data into an existing store:
useEffect(() => {
storeRef.current.setState({ product: initialState.product })
}, [initialState.product.id])
Using Zustand with React Context for SSR
If you need to share a store across multiple client components without prop drilling, you can wrap it in React Context. This is the officially recommended pattern for SSR with Zustand, because it ensures each component tree gets its own store instance.
// stores/cartContext.js
'use client'
import { createContext, useContext, useRef, useMemo } from 'react'
import { createCartStore } from './cartStore'
const CartContext = createContext(null)
export function CartProvider({ children, initialState }) {
const storeRef = useRef(null)
if (!storeRef.current) {
storeRef.current = createCartStore(initialState)
}
return (
<CartContext.Provider value={storeRef.current}>
{children}
</CartContext.Provider>
)
}
export function useCart(selector) {
const store = useContext(CartContext)
if (!store) throw new Error('useCart must be used within CartProvider')
return store(selector)
}
Usage in a server component:
// app/cart/page.js
import { CartProvider } from '@/stores/cartContext'
import CartItems from './CartItems'
import CartSummary from './CartSummary'
export default async function CartPage() {
const res = await fetch('https://api.example.com/cart', {
cache: 'no-store',
})
const cartData = await res.json()
return (
<CartProvider initialState={{ items: cartData.items }}>
<CartItems />
<CartSummary />
</CartProvider>
)
}
Child components consume the store without receiving props:
// app/cart/CartItems.js
'use client'
import { useCart } from '@/stores/cartContext'
export default function CartItems() {
const items = useCart((s) => s.items)
const removeItem = useCart((s) => s.removeItem)
return (
<ul>
{items.map((item) => (
<li key={item.id}>
{item.name}
<button onClick={() => removeItem(item.id)}>Remove</button>
</li>
))}
</ul>
)
}
Best Practices
- Never export a singleton store from a module if it will be mutated on the server. Always use a factory function or Context provider to ensure per-request isolation.
- Serialize only plain data in
initialState. Functions and class instances won't survive JSON serialization. Pass data, not methods — the store factory re-creates the methods. - Keep selectors narrow to prevent unnecessary re-renders. Use
useStore((s) => s.specificField)rather thanuseStore(). - Use
useRefto create the store once per component lifecycle. Recreating the store on every render resets state and breaks interactivity. - Match server and client initial state exactly to avoid hydration warnings. If the server renders three cart items, the client must initialize with the same three items.
- Avoid accessing browser APIs in the store creator. If you need
localStorageorwindow, guard withtypeof window !== 'undefined'or access them inuseEffect. - Use
useSyncExternalStorecompatibility. Zustand v4+ uses it internally, which is required for correct concurrent rendering and SSR hydration in React 18+. - For ISR, consider client-side refetching if users need real-time updates beyond the revalidation interval. You can add a
mutateaction that fetches fresh data and updates the store.
Debugging Common Issues
Hydration Mismatches
If you see warnings like "Text content does not match server-rendered HTML," check that your initialState is identical on both sides. A common cause is using Date.now() or Math.random() in the store creator — these produce different values on server and client.
State Leaking Between Requests
If User B sees User A's data, you're using a module-level singleton. Switch to the factory pattern or Context provider pattern described above.
Store Resetting on Navigation
If your store resets when navigating between pages, you may be recreating it on every render. Ensure you're using useRef to persist the store instance, or lift the provider to a layout component that wraps multiple pages.
Conclusion
Zustand is a lightweight yet powerful choice for state management in server-rendered React applications. The key to success is understanding the lifecycle of your store across rendering strategies: in SSR, create a fresh store per request and hydrate the client with serialized state; in SSG, bake build-time data into the initial state for instant client hydration; and in ISR, treat the store like SSG while leveraging revalidation to keep content fresh. By using factory functions, Context providers, and careful hydration, you can build fast, SEO-friendly applications with rich client-side interactivity — all without the boilerplate of heavier state libraries. Start with the per-request factory pattern for dynamic pages, adopt the Context provider when you need shared state across components, and always verify that your server and client initial states match to keep hydration clean.