How to Use Zustand with Next.js and React Server Components: A Complete Guide
To use Zustand with Next.js and React Server Components, you must create a per-request store factory using createStore from zustand/vanilla, wrap it in a React Context provider marked with 'use client', and consume it only within client components to avoid hydration mismatches and RSC constraints.
Zustand is a lightweight state management library for React, but using it with Next.js requires careful architecture to handle server-side rendering (SSR) and React Server Components (RSC). In the pmndrs/zustand repository, the recommended pattern involves isolating store instances per request and strictly separating client-side state from server components.
Why Zustand Requires Special Handling in Next.js
Next.js renders pages on the server first and then hydrates them on the client. Because a Zustand store is just a plain JavaScript object, the default export is a module-level (global) singleton. In a server-rendered environment, this causes three distinct problems that the official guides in docs/learn/guides/nextjs.md and docs/learn/guides/ssr-and-hydration.md address.
The Per-Request Store Problem
A single server process may handle many concurrent requests. A global store would be shared across those requests, leaking state between users and causing race conditions. The solution is to create a store factory that returns a fresh vanilla store for each request, and inject it via React context.
SSR Hydration Mismatches
The HTML generated on the server must match the client-side initial render, otherwise React throws hydration errors. You must initialize the store on the server, then reuse the same instance on the client by mounting the provider only on the client side.
React Server Component Limitations
React Server Components (RSCs) run only on the server and cannot call hooks or read from a mutable store. According to docs/learn/guides/nextjs.md, you must keep Zustand usage strictly inside client components ('use client'), and expose the store through a provider that client components can consume.
Creating a Per-Request Store Factory
The core implementation lives in src/vanilla.ts. Instead of using the default create export, you use createStore to build a factory function that generates isolated instances.
// src/stores/counter-store.ts
import { createStore } from 'zustand/vanilla'
export type CounterState = { count: number }
export type CounterActions = {
increment: () => void
decrement: () => void
}
export type CounterStore = CounterState & CounterActions
export const defaultInitState: CounterState = { count: 0 }
export const createCounterStore = (
initState: CounterState = defaultInitState,
) =>
createStore<CounterStore>((set) => ({
...initState,
increment: () => set((s) => ({ count: s.count + 1 })),
decrement: () => set((s) => ({ count: s.count - 1 })),
}))
This factory pattern ensures every request receives a unique store instance, preventing state leakage between users.
Providing the Store via React Context
The useStore hook in src/react.ts bridges the vanilla store to React. You must wrap this in a Context provider to ensure the store instance is preserved across the component tree.
// src/providers/counter-store-provider.tsx
'use client'
import { createContext, useContext, useState, ReactNode } from 'react'
import { useStore } from 'zustand/react'
import { createCounterStore, CounterStore } from '@/stores/counter-store'
export const CounterStoreContext = createContext<ReturnType<typeof createCounterStore> | undefined>(undefined)
export const CounterStoreProvider = ({ children }: { children: ReactNode }) => {
const [store] = useState(() => createCounterStore())
return (
<CounterStoreContext.Provider value={store}>
{children}
</CounterStoreContext.Provider>
)
}
export const useCounterStore = <T,>(selector: (s: CounterStore) => T) => {
const ctx = useContext(CounterStoreContext)
if (!ctx) throw new Error('useCounterStore must be used within CounterStoreProvider')
return useStore(ctx, selector)
}
The 'use client' directive is mandatory here because Zustand relies on React hooks and mutable state that cannot execute in React Server Components.
Implementing Zustand in Next.js Router Patterns
The provider placement depends on whether you use the Pages Router or the App Router.
Pages Router Implementation
In the Pages Router, wrap your application in _app.tsx:
// src/pages/_app.tsx
import type { AppProps } from 'next/app'
import { CounterStoreProvider } from '@/providers/counter-store-provider'
export default function App({ Component, pageProps }: AppProps) {
return (
<CounterStoreProvider>
<Component {...pageProps} />
</CounterStoreProvider>
)
}
// src/pages/index.tsx
import { useCounterStore } from '@/providers/counter-store-provider'
export default function Home() {
const { count, increment, decrement } = useCounterStore((s) => s)
return (
<div>
Count: {count}
<button onClick={increment}>+</button>
<button onClick={decrement}>‑</button>
</div>
)
}
App Router Implementation
In the App Router, the layout must also be a client component to host the provider, or you must create a client boundary:
// src/app/layout.tsx
import '@/globals.css'
import { CounterStoreProvider } from '@/providers/counter-store-provider'
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="en">
<body>
<CounterStoreProvider>{children}</CounterStoreProvider>
</body>
</RootLayout>
)
}
// src/app/page.tsx
'use client'
import { useCounterStore } from '@/providers/counter-store-provider'
export default function Page() {
const { count, increment, decrement } = useCounterStore((s) => s)
return (
<div>
Count: {count}
<button onClick={increment}>+</button>
<button onClick={decrement}>‑</button>
</div>
)
}
The 'use client' directive in page.tsx ensures that Zustand hooks execute only in the browser, preventing RSC violations.
Summary
- Use
createStorefromzustand/vanilla(src/vanilla.ts) to build a factory function that generates isolated store instances per request. - Wrap the store in a React Context provider using
useStorefromzustand/react(src/react.ts) to ensure the instance persists across the component tree. - Always add
'use client'to any file that creates or consumes the store to respect React Server Component constraints. - Place the provider in
_app.tsxfor the Pages Router orlayout.tsxfor the App Router to ensure the store is available throughout your application.
Frequently Asked Questions
Can I use Zustand directly in React Server Components?
No. According to the official Next.js guide in docs/learn/guides/nextjs.md, React Server Components cannot read from or write to a Zustand store because RSCs run only on the server and cannot access mutable client-side state or React hooks. You must mark any component that uses Zustand with the 'use client' directive.
Why does my Zustand state leak between users in Next.js?
This occurs because the default create export produces a module-level singleton. In a server environment, this singleton persists across concurrent requests, causing state to be shared between different users. The fix is to use the createStore factory pattern from src/vanilla.ts and instantiate a fresh store per request via React Context, as documented in docs/learn/guides/nextjs.md.
What is the difference between create and createStore in Zustand?
create (from zustand) is a convenience wrapper that immediately creates a singleton store and returns a hook bound to that instance, suitable for simple client-only apps. createStore (from zustand/vanilla) is the underlying factory function that returns a raw store object without React bindings, defined in src/vanilla.ts. You should use createStore when you need to instantiate stores dynamically per request or when integrating with Next.js SSR.
How do I prevent hydration mismatches with Zustand?
Hydration mismatches occur when the server-rendered HTML differs from the client's initial render. To prevent this, initialize your store state consistently on both server and client, and ensure the provider wrapping your app only initializes the store once using useState(() => createCounterStore()). According to docs/learn/guides/ssr-and-hydration.md, you should also avoid accessing browser-only APIs (like window or localStorage) during the initial server render.
Have a question about this repo?
These articles cover the highlights, but your codebase questions are specific. Give your agent direct access to the source. Share this with your agent to get started:
curl -s "https://instagit.com/install.md" Maintain an open-source project? Get it listed too →