
State Management in Next.js: Context, Zustand, or Redux Toolkit?
State management used to be one of the first architecture decisions in a React app. You picked Redux (or MobX, or something else), wired up a store at the root, and pushed most of your data through it. In a Next.js App Router project, the question looks different. Server Components fetch data directly, the URL holds a lot of UI state, and forms can submit to Server Actions without any client state at all. The amount of state that genuinely needs a client-side store is much smaller than it used to be.
That doesn't mean global client state has gone away. Shopping carts, multi-panel editors, notification queues, user preferences, and anything that several distant Client Components need to read and update still benefit from a shared store. The three options most teams consider are React Context, Zustand, and Redux Toolkit.
This post covers what changes about state management in the App Router, how to set up each option correctly (including the per-request store pattern that avoids leaking state between users), and how to choose between them.
First, Figure Out What Kind of State You Have
Before reaching for a library, sort your state into buckets. Most of it probably doesn't belong in a global store.
| Kind of state | Example | Where it should live |
|---|---|---|
| Server data | Product list, user profile, blog posts | Server Components, or a data-fetching library on the client |
| URL state | Filters, sort order, page number, selected tab | Search params |
| Form state | Field values, validation errors | Form library or useActionState |
| Local UI state | Is this dropdown open? | useState in the component |
| Shared client state | Cart contents, editor selection, toasts | Context, Zustand, or Redux Toolkit |
The common mistake is copying server data into a global store. In the App Router, a Server Component can read from your database and render the result, and revalidatePath or revalidateTag refreshes it after a mutation. Mirroring that data in Redux means you now have two sources of truth to keep in sync. If you need client-side caching of fetched data, a tool built for that job (SWR or TanStack Query) handles staleness, retries, and deduplication better than a hand-rolled slice.
Likewise, if a piece of state should survive a page refresh or be shareable as a link, put it in the URL. The post on syncing state with URL search params covers that pattern.
What remains after this sorting is usually small: a handful of values that many Client Components read and write. That's the state this post is about.
The App Router Rules That Shape Every Option
Three constraints apply no matter which tool you pick.
Stores only work in Client Components. React Context, Zustand hooks, and Redux's useSelector all rely on client-side React features. Server Components can't read from them. Any component that consumes the store needs "use client" at the top of its file (or must be imported by one that has it).
Server Components can still render providers. A Client Component that accepts children can wrap Server Component output. Your root layout stays a Server Component and renders a Providers component around {children}. The children are rendered on the server and passed through as already-rendered output.
Don't create a module-level store on the server. This is the one that bites people. If you write export const store = create(...) at the top level of a module, that store is created once per server process. Every request that renders a component reading from it shares the same instance. In the worst case, one user's cart data shows up in another user's server-rendered HTML. The fix is to create the store per request inside a Client Component provider, which is the pattern used for both Zustand and Redux below.
There's a related subtlety around hydration. Client Components render once on the server and again in the browser. If the store's initial state differs between those two renders (because you read localStorage during initialization, for example), you'll get a hydration mismatch. Initialize stores with data that is the same on both sides, then load browser-only values in an effect.
Option 1: React Context
Context is built into React, so there's nothing to install. It's a dependency-injection mechanism: you put a value at the top of a subtree, and any component below can read it with useContext (or use).
A Typed Context with a Reducer
For anything beyond a single value, pair Context with useReducer. Here's a small cart:
// app/providers/cart-provider.tsx
"use client";
import { createContext, useContext, useReducer, type ReactNode } from "react";
type CartItem = { id: string; name: string; price: number; qty: number };
type CartState = { items: CartItem[] };
type CartAction =
| { type: "add"; item: Omit<CartItem, "qty"> }
| { type: "remove"; id: string }
| { type: "clear" };
function cartReducer(state: CartState, action: CartAction): CartState {
switch (action.type) {
case "add": {
const existing = state.items.find((i) => i.id === action.item.id);
if (existing) {
return {
items: state.items.map((i) =>
i.id === action.item.id ? { ...i, qty: i.qty + 1 } : i,
),
};
}
return { items: [...state.items, { ...action.item, qty: 1 }] };
}
case "remove":
return { items: state.items.filter((i) => i.id !== action.id) };
case "clear":
return { items: [] };
}
}
const CartStateContext = createContext<CartState | null>(null);
const CartDispatchContext = createContext<React.Dispatch<CartAction> | null>(
null,
);
export function CartProvider({
children,
initialItems = [],
}: {
children: ReactNode;
initialItems?: CartItem[];
}) {
const [state, dispatch] = useReducer(cartReducer, { items: initialItems });
return (
<CartStateContext value={state}>
<CartDispatchContext value={dispatch}>{children}</CartDispatchContext>
</CartStateContext>
);
}
export function useCart() {
const ctx = useContext(CartStateContext);
if (!ctx) throw new Error("useCart must be used inside CartProvider");
return ctx;
}
export function useCartDispatch() {
const ctx = useContext(CartDispatchContext);
if (!ctx) throw new Error("useCartDispatch must be used inside CartProvider");
return ctx;
}
A few details are worth calling out:
- In React 19, you can render a context directly as a provider (
<CartStateContext value={...}>) instead of<CartStateContext.Provider>. Both work. - State and dispatch live in separate contexts. Components that only dispatch (an "Add to cart" button) don't re-render when the cart changes, because the
dispatchfunction identity is stable. - The custom hooks throw if used outside the provider, which turns a confusing
nullbug into a clear error.
Wire it into the root layout:
// app/layout.tsx
import type { ReactNode } from "react";
import { CartProvider } from "./providers/cart-provider";
export default function RootLayout({ children }: { children: ReactNode }) {
return (
<html lang="en">
<body>
<CartProvider>{children}</CartProvider>
</body>
</html>
);
}
Because CartProvider calls useReducer inside a component, each request and each browser session gets its own state. Context has no module-level store, so the cross-request leak can't happen.
Where Context Falls Short
Context's weakness is re-rendering. Every component that reads a context re-renders whenever the context value changes, even if it only cares about one field. A header badge that shows the item count re-renders when an item's quantity changes, which is fine, but so does every other consumer of CartStateContext. For a cart, that's acceptable. For state that updates frequently (cursor position in an editor, a value tied to a slider) and is read by many components, it becomes a performance problem.
You can mitigate this by splitting contexts, memoizing values, and wrapping consumers in memo, but at that point you're rebuilding what a store library gives you for free: selector-based subscriptions.
Context is a great fit for:
- Values that change rarely: theme, locale, the current user, feature flags.
- Small, contained state shared within one section of the app.
- Dependency injection (passing a configured client or a store instance down the tree, which is exactly how the Zustand and Redux setups below use it).
Option 2: Zustand
Zustand is a small store library with a hook-based API. Components subscribe to slices of state with a selector, and only re-render when that slice changes. It has no providers by default, which is convenient in a plain React app but is exactly what you need to avoid on a server. For the App Router, the recommended pattern is a store factory plus a Context provider that holds one store per request.
npm install zustand
Define a Store Factory
Use createStore from zustand/vanilla, which creates a store without binding it to a hook. Wrapping it in a function means you can create a fresh instance whenever you need one.
// stores/cart-store.ts
import { createStore } from "zustand/vanilla";
export type CartItem = { id: string; name: string; price: number; qty: number };
export type CartState = { items: CartItem[] };
export type CartActions = {
add: (item: Omit<CartItem, "qty">) => void;
remove: (id: string) => void;
clear: () => void;
};
export type CartStore = CartState & CartActions;
export const defaultCartState: CartState = { items: [] };
export function createCartStore(initState: CartState = defaultCartState) {
return createStore<CartStore>()((set) => ({
...initState,
add: (item) =>
set((state) => {
const existing = state.items.find((i) => i.id === item.id);
if (existing) {
return {
items: state.items.map((i) =>
i.id === item.id ? { ...i, qty: i.qty + 1 } : i,
),
};
}
return { items: [...state.items, { ...item, qty: 1 }] };
}),
remove: (id) =>
set((state) => ({ items: state.items.filter((i) => i.id !== id) })),
clear: () => set({ items: [] }),
}));
}
The set function merges the returned object into the existing state, so actions only need to return the fields they change.
Provide One Store per Request
// providers/cart-store-provider.tsx
"use client";
import { createContext, useContext, useState, type ReactNode } from "react";
import { useStore } from "zustand";
import {
createCartStore,
type CartState,
type CartStore,
} from "@/stores/cart-store";
type CartStoreApi = ReturnType<typeof createCartStore>;
const CartStoreContext = createContext<CartStoreApi | null>(null);
export function CartStoreProvider({
children,
initialState,
}: {
children: ReactNode;
initialState?: CartState;
}) {
// The initializer runs once per provider instance, so each request
// (and each browser session) gets its own store.
const [store] = useState(() => createCartStore(initialState));
return <CartStoreContext value={store}>{children}</CartStoreContext>;
}
export function useCartStore<T>(selector: (store: CartStore) => T): T {
const store = useContext(CartStoreContext);
if (!store) {
throw new Error("useCartStore must be used inside CartStoreProvider");
}
return useStore(store, selector);
}
Context here carries the store instance, not the state. The instance never changes, so the provider doesn't trigger re-renders. Subscriptions happen through useStore, which compares the selected value and only re-renders the component when it changes.
Use It in Client Components
// app/components/cart-badge.tsx
"use client";
import { useCartStore } from "@/providers/cart-store-provider";
export function CartBadge() {
const count = useCartStore((s) => s.items.reduce((n, i) => n + i.qty, 0));
return <span aria-label="Items in cart">{count}</span>;
}
// app/components/add-to-cart-button.tsx
"use client";
import { useCartStore } from "@/providers/cart-store-provider";
export function AddToCartButton({
id,
name,
price,
}: {
id: string;
name: string;
price: number;
}) {
const add = useCartStore((s) => s.add);
return <button onClick={() => add({ id, name, price })}>Add to cart</button>;
}
CartBadge selects a number, so it re-renders only when the total count changes. AddToCartButton selects the add action, which is a stable function, so it never re-renders because of cart changes.
One thing to watch: a selector that returns a new object or array on every call (like (s) => ({ items: s.items, clear: s.clear })) will cause re-renders on every store update, and in Zustand v5 it can trigger an infinite loop warning. Select individual values, or wrap the selector with useShallow from zustand/react/shallow when you need several fields at once.
Seeding the Store from the Server
The provider's initialState prop lets a Server Component pass data in. For example, a layout can read the user's saved cart and hand it to the provider:
// app/(shop)/layout.tsx
import type { ReactNode } from "react";
import { CartStoreProvider } from "@/providers/cart-store-provider";
import { getSavedCart } from "@/lib/cart";
export default async function ShopLayout({
children,
}: {
children: ReactNode;
}) {
const items = await getSavedCart();
return (
<CartStoreProvider initialState={{ items }}>{children}</CartStoreProvider>
);
}
The data must be serializable (plain objects, arrays, strings, numbers), since it crosses from a Server Component to a Client Component as props. Keep in mind that reading per-user data like this in a layout makes that segment dynamic.
Option 3: Redux Toolkit
Redux Toolkit (RTK) is the official, batteries-included way to write Redux. It replaces the old boilerplate with createSlice and configureStore, ships with Immer so you can write "mutating" reducer logic safely, and includes Redux DevTools integration out of the box. It also brings RTK Query, a data-fetching and caching layer, if you want it.
npm install @reduxjs/toolkit react-redux
A Slice and a Store Factory
// lib/features/cart-slice.ts
import { createSlice, type PayloadAction } from "@reduxjs/toolkit";
type CartItem = { id: string; name: string; price: number; qty: number };
type CartState = { items: CartItem[] };
const initialState: CartState = { items: [] };
export const cartSlice = createSlice({
name: "cart",
initialState,
reducers: {
add(state, action: PayloadAction<Omit<CartItem, "qty">>) {
const existing = state.items.find((i) => i.id === action.payload.id);
if (existing) {
existing.qty += 1;
} else {
state.items.push({ ...action.payload, qty: 1 });
}
},
remove(state, action: PayloadAction<string>) {
state.items = state.items.filter((i) => i.id !== action.payload);
},
clear(state) {
state.items = [];
},
},
});
export const { add, remove, clear } = cartSlice.actions;
The reducers look like they mutate state, but Immer turns those writes into immutable updates. That makes nested updates much easier to read than spread-heavy code.
// lib/store.ts
import { configureStore } from "@reduxjs/toolkit";
import { cartSlice } from "./features/cart-slice";
export function makeStore() {
return configureStore({
reducer: {
cart: cartSlice.reducer,
},
});
}
export type AppStore = ReturnType<typeof makeStore>;
export type RootState = ReturnType<AppStore["getState"]>;
export type AppDispatch = AppStore["dispatch"];
Just like with Zustand, the store is created by a function, not at module level.
Typed Hooks
// lib/hooks.ts
import { useDispatch, useSelector } from "react-redux";
import type { AppDispatch, RootState } from "./store";
export const useAppDispatch = useDispatch.withTypes<AppDispatch>();
export const useAppSelector = useSelector.withTypes<RootState>();
withTypes gives you pre-typed hooks, so components don't need to annotate RootState every time they select something.
The Store Provider
// app/store-provider.tsx
"use client";
import { useRef, type ReactNode } from "react";
import { Provider } from "react-redux";
import { makeStore, type AppStore } from "@/lib/store";
export default function StoreProvider({ children }: { children: ReactNode }) {
const storeRef = useRef<AppStore | null>(null);
if (!storeRef.current) {
storeRef.current = makeStore();
}
return <Provider store={storeRef.current}>{children}</Provider>;
}
The useRef check ensures the store is created exactly once per provider instance. Render StoreProvider in your root layout (or in the layout of the section that needs it), the same way as the Context example.
Using It
// app/components/cart-summary.tsx
"use client";
import { useAppDispatch, useAppSelector } from "@/lib/hooks";
import { clear } from "@/lib/features/cart-slice";
export function CartSummary() {
const items = useAppSelector((state) => state.cart.items);
const dispatch = useAppDispatch();
const total = items.reduce((sum, i) => sum + i.price * i.qty, 0);
return (
<div>
<p>
{items.length} products, total ${total.toFixed(2)}
</p>
<button onClick={() => dispatch(clear())}>Clear cart</button>
</div>
);
}
If you need to seed the store from server data, dispatch an action on the fresh store inside the same if (!storeRef.current) block, for example storeRef.current.dispatch(setItems(initialItems)), passing initialItems as a prop from the layout.
Where Redux Toolkit Shines
RTK is more code than Zustand for the same feature, but you get things in return:
- Redux DevTools with time-travel debugging and an action log, enabled by default in
configureStore. - Strong conventions. Slices, actions, and selectors follow a predictable shape, which helps large teams.
- Middleware for logging, analytics, and side-effect handling (
createListenerMiddleware). - RTK Query if you want an integrated client-side data layer.
Side-by-Side Comparison
| React Context | Zustand | Redux Toolkit | |
|---|---|---|---|
| Install | Built in | zustand (very small) | @reduxjs/toolkit + react-redux |
| Boilerplate | Low for simple values, grows with reducers | Low | Moderate |
| Re-render control | All consumers re-render on change | Selector-based | Selector-based |
| DevTools | React DevTools only | Optional devtools middleware | Redux DevTools built in |
| Middleware / side effects | Manual | Middleware (persist, devtools, immer) | Listener middleware, thunks, RTK Query |
| App Router setup | Provider with useReducer | Store factory + Context provider | Store factory + Provider with useRef |
| Best for | Rarely changing values, small scopes | Most shared client state | Large apps, big teams, complex flows |
How to Choose
Here's a practical decision path:
- Can the state live on the server, in the URL, or in a form? Put it there. This removes most candidates.
- Is it a value that rarely changes, like theme or current user? Use Context.
- Is it shared, frequently updated client state in a small-to-medium app? Use Zustand. You get selector-based subscriptions with very little code.
- Is it a large app with many contributors, complex state transitions, or an existing Redux codebase? Use Redux Toolkit. The structure and tooling pay for themselves at scale.
It's also fine to mix them. A common setup is Context for theme and auth, Zustand for a few interactive features, and Server Components for everything that comes from the database. There's no rule that says one app gets one state tool.
Common Pitfalls
Exporting a singleton store. export const useCart = create(...) works in a client-only React app, but in the App Router it creates a store shared across server requests. Use the factory-plus-provider pattern.
Reading localStorage during store initialization. The server has no localStorage, so the server render and client render produce different HTML. Load persisted values in a useEffect after mount, or use Zustand's persist middleware with skipHydration and call rehydrate() in an effect.
Wrapping the entire app in "use client". You only need the provider itself to be a Client Component. Pages and layouts below it can stay Server Components, as explained in the Next.js docs on context providers.
Putting fetched data in the store by default. If a Server Component can render it, let it. Use revalidatePath or revalidateTag after mutations instead of syncing a client cache by hand.
Conclusion
The App Router shrinks the job of client-side state management. Server data, URL state, and form state each have better homes, and what's left is a small set of shared, interactive values. For those, Context handles slow-changing values, Zustand covers most interactive state with minimal code, and Redux Toolkit brings structure and tooling for large applications.
Whichever you choose, follow the same rule: create the store inside a Client Component provider, once per request, and keep the initial state identical on the server and the client.


