React SME Cookbook
All FAQs

Search Documentation

Search across all documentation pages

zustandnextjsssrhydrationapp-router

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.
  • createStore from zustand/vanilla creates a store instance without React hooks. Wrapping it in Context + useRef ensures 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 initialState to the Client Provider, which seeds the store.
  • useStore(store, selector) from Zustand connects a vanilla store to React with selector-based subscriptions.
  • The persist middleware's storage adapter must handle the server environment where localStorage is 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 segment

TypeScript Notes

  • Use createStore instead of create for 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.
  • persist middleware accesses localStorage on import, which throws in server environments. Guard with typeof 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.
  • useRef for the store prevents re-creation, but it also means initialState changes 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 onRehydrateStorage callback runs after the initial render. Components may flash default state briefly.

Alternatives

ApproachProsCons
Context + createStoreSSR-safe, per-request isolationMore boilerplate than singleton
Module-level create()Simple, no provider neededLeaks state in SSR
Redux Toolkit + next-redux-wrapperMature SSR patternHeavy, complex setup
Jotai with ProviderAtomic, SSR-safe with ProviderDifferent 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 + createStore pattern for per-request isolation.
What is the Context + createStore pattern for Next.js?
  • Use createStore from zustand/vanilla to 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 initialState to 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?
  • useRef maintains 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 initialState after 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() (not createStore) for this simpler pattern.
Gotcha: What happens if the persist middleware accesses localStorage during SSR?
  • It throws because localStorage is 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?
  • create returns a React hook (UseBoundStore<StoreApi<State>>).
  • createStore returns 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 T infers the return type from the selector function.