Zustand with Next.js
Recipe
In Next.js App Router, Zustand stores are singletons that persist across requests on the server. Create per-request store instances using createStore + Context to avoid shared state between users.
// stores/counter-store.ts
import { createStore } from "zustand/vanilla";
export interface CounterState {
count: number;
increment: () => void;
decrement: () => void;
}
export const createCounterStore = (initialCount = 0) =>
createStore<CounterState>((set) => ({
count: initialCount,
increment: () => set((s) => ({ count: s.count + 1 })),
decrement: () => set((s) => ({ count: s.count - 1 })),
}));
export type CounterStore = ReturnType<typeof createCounterStore>;// providers/counter-provider.tsx
"use client";
import { createContext, useContext, useRef, ReactNode } from "react";
import { useStore } from "zustand";
import { createCounterStore, CounterState, CounterStore } from "@/stores/counter-store";
const CounterContext = createContext<CounterStore | null>(null);
export function CounterProvider({
children,
initialCount,
}: {
children: ReactNode;
initialCount?: number;
}) {
const storeRef = useRef<CounterStore>();
if (!storeRef.current) {
storeRef.current = createCounterStore(initialCount);
}
return (
<CounterContext.Provider value={storeRef.current}>
{children}
</CounterContext.Provider>
);
}
export function useCounterStore<T>(selector: (state: CounterState) => T): T {
const store = useContext(CounterContext);
if (!store) throw new Error("useCounterStore must be used within CounterProvider");
return useStore(store, selector);
}Working Example
// stores/app-store.ts
import { createStore } from "zustand/vanilla";
import { persist, createJSONStorage } from "zustand/middleware";
export interface AppState {
user: { id: string; name: string; email: string } | null;
theme: "light" | "dark";
setUser: (user: AppState["user"]) => void;
setTheme: (theme: AppState["theme"]) => void;
logout: () => void;
}
export type AppStoreType = ReturnType<typeof createAppStore>;
export const createAppStore = (initState?: Partial<AppState>) =>
createStore<AppState>()(
persist(
(set) => ({
user: null,
theme: "light",
...initState,
setUser: (user) => set({ user }),
setTheme: (theme) => set({ theme }),
logout: () => set({ user: null }),
}),
{
name: "app-storage",
storage: createJSONStorage(() =>
typeof window !== "undefined"
? localStorage
: {
getItem: () => null,
setItem: () => {},
removeItem: () => {},
}
),
partialize: (state) => ({ theme: state.theme }),
}
)
);// providers/app-provider.tsx
"use client";
import { createContext, useContext, useRef, ReactNode } from "react";
import { useStore } from "zustand";
import { createAppStore, AppState, AppStoreType } from "@/stores/app-store";
const AppStoreContext = createContext<AppStoreType | null>(null);
interface AppProviderProps {
children: ReactNode;
initialState?: Partial<AppState>;
}
export function AppProvider({ children, initialState }: AppProviderProps) {
const storeRef = useRef<AppStoreType>();
if (!storeRef.current) {
storeRef.current = createAppStore(initialState);
}
return (
<AppStoreContext.Provider value={storeRef.current}>
{children}
</AppStoreContext.Provider>
);
}
export function useAppStore<T>(selector: (state: AppState) => T): T {
const store = useContext(AppStoreContext);
if (!store) throw new Error("useAppStore must be used within AppProvider");
return useStore(store, selector);
}// app/layout.tsx (Server Component)
import { AppProvider } from "@/providers/app-provider";
import { cookies } from "next/headers";
async function getServerUser() {
const cookieStore = await cookies();
const token = cookieStore.get("auth_token")?.value;
if (!token) return null;
const res = await fetch("https://api.example.com/me", {
headers: { Authorization: `Bearer ${token}` },
});
if (!res.ok) return null;
return res.json();
}
export default async function RootLayout({ children }: { children: React.ReactNode }) {
const user = await getServerUser();
return (
<html lang="en">
<body>
<AppProvider initialState={{ user }}>
{children}
</AppProvider>
</body>
</html>
);
}// app/dashboard/page.tsx (Server Component)
import { DashboardClient } from "./dashboard-client";
async function getDashboardData() {
const res = await fetch("https://api.example.com/dashboard", {
next: { revalidate: 60 },
});
return res.json();
}
export default async function DashboardPage() {
const data = await getDashboardData();
return <DashboardClient serverData={data} />;
}// app/dashboard/dashboard-client.tsx
"use client";
import { useAppStore } from "@/providers/app-provider";
export function DashboardClient({ serverData }: { serverData: any }) {
const user = useAppStore((s) => s.user);
const theme = useAppStore((s) => s.theme);
return (
<div className={theme === "dark" ? "bg-gray-900 text-white" : "bg-white"}>
<h1>Welcome, {user?.name ?? "Guest"}</h1>
<pre>{JSON.stringify(serverData, null, 2)}</pre>
</div>
);
}Deep Dive
How It Works
- In Next.js App Router, modules are loaded once on the server and shared across all requests. A module-level
create()store would leak state between users. createStorefromzustand/vanillacreates a store instance without React hooks. Wrapping it in Context +useRefensures one instance per React tree.- The Provider creates the store once on mount (via
useRef) and never recreates it, even across re-renders. - Server Components fetch data and pass it as
initialStateto the Client Provider, which seeds the store. useStore(store, selector)from Zustand connects a vanilla store to React with selector-based subscriptions.- The
persistmiddleware's storage adapter must handle the server environment wherelocalStorageis undefined.
Variations
Simple singleton store (use only if SSR state leaking is acceptable):
// Acceptable for truly global state like feature flags that are
// the same for all users
import { create } from "zustand";
export const useFeatureFlags = create<{ flags: Record<string, boolean> }>(() => ({
flags: {},
}));Hydration-safe persist with onRehydrateStorage:
createStore<State>()(
persist(storeCreator, {
name: "store",
onRehydrateStorage: () => {
return (state, error) => {
if (error) console.error("Hydration failed:", error);
else console.log("Hydrated:", state);
};
},
})
);Per-route stores:
// app/checkout/layout.tsx
import { CheckoutProvider } from "@/providers/checkout-provider";
export default function CheckoutLayout({ children }: { children: React.ReactNode }) {
return <CheckoutProvider>{children}</CheckoutProvider>;
}
// Store only exists within the checkout route segmentTypeScript Notes
- Use
createStoreinstead ofcreatefor vanilla stores in Next.js. The types differ. - The provider pattern requires typing the context, the selector hook, and the store creator separately.
import { createStore, StoreApi } from "zustand/vanilla";
import { useStore } from "zustand";
type MyStore = StoreApi<MyState>;
// Typed context
const StoreContext = createContext<MyStore | null>(null);
// Typed selector hook
function useMyStore<T>(selector: (state: MyState) => T): T {
const store = useContext(StoreContext);
if (!store) throw new Error("Missing provider");
return useStore(store, selector);
}Gotchas
- A module-level
create()store in Next.js App Router shares state across all server-rendered requests. User A could see User B's data. Always use the Context + createStore pattern for user-specific state. persistmiddleware accesseslocalStorageon import, which throws in server environments. Guard withtypeof window !== "undefined"or use a no-op storage on the server.- Hydration mismatches occur when the server-rendered HTML uses default state but the client hydrates with persisted state. Use a hydration gate or suppress hydration warnings for known differences.
useReffor the store prevents re-creation, but it also meansinitialStatechanges after the first render are ignored. If you need to reinitialize, use a key prop on the Provider.- Do not import Client Component hooks (
useAppStore) in Server Components. Server Components cannot use hooks. - The persist middleware's
onRehydrateStoragecallback runs after the initial render. Components may flash default state briefly.
Alternatives
| Approach | Pros | Cons |
|---|---|---|
| Context + createStore | SSR-safe, per-request isolation | More boilerplate than singleton |
| Module-level create() | Simple, no provider needed | Leaks state in SSR |
| Redux Toolkit + next-redux-wrapper | Mature SSR pattern | Heavy, complex setup |
| Jotai with Provider | Atomic, SSR-safe with Provider | Different paradigm |
FAQs
Why can't you use a module-level create() store in Next.js App Router for user-specific state?
- Modules are loaded once on the server and shared across all requests.
- A singleton store would leak User A's state into User B's request.
- Use the Context +
createStorepattern for per-request isolation.
What is the Context + createStore pattern for Next.js?
- Use
createStorefromzustand/vanillato create store instances (not React hooks). - Wrap it in a React Context provider that creates one instance per mount (via
useRef). - Expose a typed selector hook using
useStore(store, selector).
How do you pass server-fetched data into a Zustand store in Next.js?
- Fetch data in a Server Component and pass it as
initialStateto the Client Provider. - The Provider seeds the store with this data on first render.
// Server Component
const user = await getServerUser();
return <AppProvider initialState={{ user }}>{children}</AppProvider>;Why does useRef prevent the store from being recreated on re-renders?
useRefmaintains the same value across renders without triggering re-renders.- The store is created once on mount inside the
if (!storeRef.current)check. - This also means changes to
initialStateafter the first render are ignored.
When is a simple singleton store acceptable in Next.js?
- For truly global state that is the same for all users (e.g., feature flags).
- When SSR state leaking is not a concern.
- Use
create()(notcreateStore) for this simpler pattern.
Gotcha: What happens if the persist middleware accesses localStorage during SSR?
- It throws because
localStorageis not available on the server. - Guard with
typeof window !== "undefined"or provide a no-op storage for the server.
Gotcha: Why might you see a hydration mismatch with persisted state?
- Server-rendered HTML uses default state, but the client hydrates with persisted (different) state.
- This causes a mismatch. Use a hydration gate or suppress warnings for known differences.
How do you scope a Zustand store to a specific route segment?
// app/checkout/layout.tsx
export default function CheckoutLayout({ children }) {
return <CheckoutProvider>{children}</CheckoutProvider>;
}- Place the provider in a route layout. The store only exists within that route segment.
Can you import a Zustand hook (like useAppStore) in a Server Component?
- No. Server Components cannot use hooks.
- Only import and use Zustand hooks in Client Components marked with
"use client".
What is the difference between create and createStore in TypeScript?
createreturns a React hook (UseBoundStore<StoreApi<State>>).createStorereturns a vanilla store (StoreApi<State>) without React bindings.- Use
createStore+useStore(store, selector)for the Next.js provider pattern.
import { createStore, StoreApi } from "zustand/vanilla";
type MyStore = StoreApi<MyState>;How do you type the provider's selector hook in TypeScript?
function useAppStore<T>(selector: (state: AppState) => T): T {
const store = useContext(AppStoreContext);
if (!store) throw new Error("Missing provider");
return useStore(store, selector);
}- The generic
Tinfers the return type from the selector function.