← Back to DevBytes

Server-Side Rendering with Zustand: SSR, SSG, ISR

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

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.

— Ad —

Google AdSense will appear here after approval

← Back to all articles