Zustand + Next.js App Router: Modern React State Management from Zero to Production

Lightweight state management that just works. Zustand is the minimalist, hook-based state manager that has become the go-to choice for React developers in 2026. In this tutorial, you will build a real-world shopping cart application with Next.js 15 App Router, learning every Zustand pattern you need for production.
What You Will Learn
By the end of this tutorial, you will:
- Set up Zustand in a Next.js 15 App Router project with TypeScript
- Create type-safe stores with actions, selectors, and computed values
- Use middleware for logging, persistence, and devtools integration
- Handle server-side rendering and hydration with Zustand stores
- Implement store slices to organize complex state
- Build persistent state with
localStorageand custom storage engines - Apply real-world patterns for a production shopping cart
Prerequisites
Before starting, ensure you have:
- Node.js 20+ installed (
node --version) - TypeScript experience (types, generics, hooks)
- Familiarity with Next.js App Router basics (layouts, pages, server/client components)
- Basic understanding of React state (
useState,useContext)
Why Zustand in 2026?
React state management has evolved dramatically. While Redux dominated for years, the ecosystem has shifted toward simpler, more ergonomic solutions. Here is why Zustand stands out:
| Feature | Zustand | Redux Toolkit | Jotai | Context API |
|---|---|---|---|---|
| Bundle size | ~1 KB | ~12 KB | ~3 KB | 0 KB (built-in) |
| Boilerplate | Minimal | Moderate | Minimal | High at scale |
| TypeScript DX | Excellent | Good | Good | Manual |
| SSR support | Built-in | Extra setup | Built-in | Built-in |
| Devtools | Via middleware | Built-in | Extension | None |
| Learning curve | Low | Moderate | Low | Low (scales poorly) |
Zustand gives you the simplicity of useState with the power of a global store, no providers required.
Step 1: Project Setup
Create a new Next.js 15 project with TypeScript:
npx create-next-app@latest zustand-cart --typescript --tailwind --app --src-dir
cd zustand-cartInstall Zustand:
npm install zustandYour project structure should look like this:
zustand-cart/
├── src/
│ ├── app/
│ │ ├── layout.tsx
│ │ ├── page.tsx
│ │ └── cart/
│ │ └── page.tsx
│ ├── components/
│ ├── stores/
│ └── types/
├── package.json
└── tsconfig.json
Create the directories we will need:
mkdir -p src/stores src/types src/componentsStep 2: Define Your Types
Start with clean type definitions. Create src/types/cart.ts:
// src/types/cart.ts
export interface Product {
id: string;
name: string;
price: number;
image: string;
category: string;
}
export interface CartItem {
product: Product;
quantity: number;
}
export interface CartSummary {
totalItems: number;
totalPrice: number;
discount: number;
finalPrice: number;
}Step 3: Create Your First Zustand Store
This is where Zustand shines. Create src/stores/cart-store.ts:
// src/stores/cart-store.ts
import { create } from "zustand";
import type { Product, CartItem } from "@/types/cart";
interface CartState {
// State
items: CartItem[];
isOpen: boolean;
// Actions
addItem: (product: Product) => void;
removeItem: (productId: string) => void;
updateQuantity: (productId: string, quantity: number) => void;
clearCart: () => void;
toggleCart: () => void;
// Computed (derived state)
getTotalItems: () => number;
getTotalPrice: () => number;
}
export const useCartStore = create<CartState>((set, get) => ({
// Initial state
items: [],
isOpen: false,
// Actions
addItem: (product) =>
set((state) => {
const existingItem = state.items.find(
(item) => item.product.id === product.id
);
if (existingItem) {
return {
items: state.items.map((item) =>
item.product.id === product.id
? { ...item, quantity: item.quantity + 1 }
: item
),
};
}
return { items: [...state.items, { product, quantity: 1 }] };
}),
removeItem: (productId) =>
set((state) => ({
items: state.items.filter((item) => item.product.id !== productId),
})),
updateQuantity: (productId, quantity) =>
set((state) => ({
items:
quantity <= 0
? state.items.filter((item) => item.product.id !== productId)
: state.items.map((item) =>
item.product.id === productId ? { ...item, quantity } : item
),
})),
clearCart: () => set({ items: [] }),
toggleCart: () => set((state) => ({ isOpen: !state.isOpen })),
// Computed values using get()
getTotalItems: () =>
get().items.reduce((total, item) => total + item.quantity, 0),
getTotalPrice: () =>
get().items.reduce(
(total, item) => total + item.product.price * item.quantity,
0
),
}));Notice how clean this is compared to Redux. No action types, no reducers, no dispatch. Just a function that returns state and actions.
Step 4: Use the Store in Components
Product Card Component
Create src/components/product-card.tsx:
// src/components/product-card.tsx
"use client";
import { useCartStore } from "@/stores/cart-store";
import type { Product } from "@/types/cart";
interface ProductCardProps {
product: Product;
}
export function ProductCard({ product }: ProductCardProps) {
const addItem = useCartStore((state) => state.addItem);
return (
<div className="rounded-lg border p-4 shadow-sm hover:shadow-md transition-shadow">
<div className="aspect-square bg-gray-100 rounded-md mb-3 flex items-center justify-center">
<span className="text-4xl">{product.image}</span>
</div>
<h3 className="font-semibold text-lg">{product.name}</h3>
<p className="text-gray-500 text-sm">{product.category}</p>
<div className="flex items-center justify-between mt-3">
<span className="text-xl font-bold">${product.price.toFixed(2)}</span>
<button
onClick={() => addItem(product)}
className="bg-blue-600 text-white px-4 py-2 rounded-md hover:bg-blue-700 transition-colors"
>
Add to Cart
</button>
</div>
</div>
);
}Cart Badge Component
Create src/components/cart-badge.tsx:
// src/components/cart-badge.tsx
"use client";
import { useCartStore } from "@/stores/cart-store";
export function CartBadge() {
// Subscribe to only what you need - this is key for performance
const totalItems = useCartStore((state) => state.getTotalItems());
const toggleCart = useCartStore((state) => state.toggleCart);
return (
<button
onClick={toggleCart}
className="relative p-2 rounded-full hover:bg-gray-100"
>
<svg
className="w-6 h-6"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M3 3h2l.4 2M7 13h10l4-8H5.4M7 13L5.4 5M7 13l-2.293 2.293c-.63.63-.184 1.707.707 1.707H17m0 0a2 2 0 100 4 2 2 0 000-4zm-8 2a2 2 0 100 4 2 2 0 000-4z"
/>
</svg>
{totalItems > 0 && (
<span className="absolute -top-1 -right-1 bg-red-500 text-white text-xs rounded-full w-5 h-5 flex items-center justify-center">
{totalItems}
</span>
)}
</button>
);
}Key insight: Notice how we use (state) => state.getTotalItems() as a selector. Zustand only re-renders a component when the selected value changes. This is automatic performance optimization.
Step 5: Selectors and Performance
Zustand re-renders components only when the selected state changes. This is critical for performance. Here are the patterns:
Bad: Selecting the entire store
// This re-renders on ANY state change
const store = useCartStore();Good: Selecting specific values
// Only re-renders when items change
const items = useCartStore((state) => state.items);
// Only re-renders when isOpen changes
const isOpen = useCartStore((state) => state.isOpen);Advanced: Custom equality functions
For derived values that return new object references, use shallow comparison:
import { useShallow } from "zustand/react/shallow";
// Only re-renders when the actual values change, not reference
const { items, isOpen } = useCartStore(
useShallow((state) => ({
items: state.items,
isOpen: state.isOpen,
}))
);Creating Reusable Selectors
Create src/stores/cart-selectors.ts:
// src/stores/cart-selectors.ts
import type { CartState } from "@/stores/cart-store";
export const selectCartItems = (state: CartState) => state.items;
export const selectIsCartOpen = (state: CartState) => state.isOpen;
export const selectCartTotal = (state: CartState) => state.getTotalPrice();
export const selectCartCount = (state: CartState) => state.getTotalItems();
export const selectItemById = (productId: string) => (state: CartState) =>
state.items.find((item) => item.product.id === productId);Usage:
const items = useCartStore(selectCartItems);
const total = useCartStore(selectCartTotal);
const item = useCartStore(selectItemById("product-1"));Step 6: Middleware - Persistence
One of Zustand's most powerful features is its middleware system. Let us add persistence to save the cart in localStorage.
Update src/stores/cart-store.ts:
// src/stores/cart-store.ts
import { create } from "zustand";
import { persist, createJSONStorage } from "zustand/middleware";
import type { Product, CartItem } from "@/types/cart";
interface CartState {
items: CartItem[];
isOpen: boolean;
addItem: (product: Product) => void;
removeItem: (productId: string) => void;
updateQuantity: (productId: string, quantity: number) => void;
clearCart: () => void;
toggleCart: () => void;
getTotalItems: () => number;
getTotalPrice: () => number;
}
export const useCartStore = create<CartState>()(
persist(
(set, get) => ({
items: [],
isOpen: false,
addItem: (product) =>
set((state) => {
const existingItem = state.items.find(
(item) => item.product.id === product.id
);
if (existingItem) {
return {
items: state.items.map((item) =>
item.product.id === product.id
? { ...item, quantity: item.quantity + 1 }
: item
),
};
}
return { items: [...state.items, { product, quantity: 1 }] };
}),
removeItem: (productId) =>
set((state) => ({
items: state.items.filter((item) => item.product.id !== productId),
})),
updateQuantity: (productId, quantity) =>
set((state) => ({
items:
quantity <= 0
? state.items.filter((item) => item.product.id !== productId)
: state.items.map((item) =>
item.product.id === productId ? { ...item, quantity } : item
),
})),
clearCart: () => set({ items: [] }),
toggleCart: () => set((state) => ({ isOpen: !state.isOpen })),
getTotalItems: () =>
get().items.reduce((total, item) => total + item.quantity, 0),
getTotalPrice: () =>
get().items.reduce(
(total, item) => total + item.product.price * item.quantity,
0
),
}),
{
name: "cart-storage",
storage: createJSONStorage(() => localStorage),
// Only persist items, not UI state like isOpen
partialize: (state) => ({ items: state.items }),
}
)
);The partialize option is important: you should only persist data state, not UI state like whether the cart drawer is open.
Step 7: Handling SSR and Hydration
Next.js App Router renders components on the server first. Since localStorage does not exist on the server, we need to handle hydration carefully.
Create src/stores/hydration.ts:
// src/stores/hydration.ts
"use client";
import { useEffect, useState } from "react";
export function useHydration() {
const [hydrated, setHydrated] = useState(false);
useEffect(() => {
setHydrated(true);
}, []);
return hydrated;
}Create a hydration-safe cart component at src/components/cart-sidebar.tsx:
// src/components/cart-sidebar.tsx
"use client";
import { useCartStore } from "@/stores/cart-store";
import { useHydration } from "@/stores/hydration";
export function CartSidebar() {
const hydrated = useHydration();
const items = useCartStore((state) => state.items);
const isOpen = useCartStore((state) => state.isOpen);
const toggleCart = useCartStore((state) => state.toggleCart);
const removeItem = useCartStore((state) => state.removeItem);
const updateQuantity = useCartStore((state) => state.updateQuantity);
const clearCart = useCartStore((state) => state.clearCart);
const getTotalPrice = useCartStore((state) => state.getTotalPrice);
if (!isOpen) return null;
return (
<div className="fixed inset-0 z-50 flex justify-end">
{/* Backdrop */}
<div className="absolute inset-0 bg-black/50" onClick={toggleCart} />
{/* Sidebar */}
<div className="relative w-full max-w-md bg-white shadow-xl p-6 overflow-y-auto">
<div className="flex justify-between items-center mb-6">
<h2 className="text-2xl font-bold">Your Cart</h2>
<button onClick={toggleCart} className="text-gray-500 hover:text-gray-700">
✕
</button>
</div>
{!hydrated ? (
<div className="animate-pulse space-y-4">
{[1, 2, 3].map((i) => (
<div key={i} className="h-20 bg-gray-200 rounded" />
))}
</div>
) : items.length === 0 ? (
<p className="text-gray-500 text-center py-8">Your cart is empty</p>
) : (
<>
<div className="space-y-4">
{items.map((item) => (
<div
key={item.product.id}
className="flex items-center gap-4 border-b pb-4"
>
<span className="text-3xl">{item.product.image}</span>
<div className="flex-1">
<h3 className="font-medium">{item.product.name}</h3>
<p className="text-gray-500">
${item.product.price.toFixed(2)}
</p>
</div>
<div className="flex items-center gap-2">
<button
onClick={() =>
updateQuantity(item.product.id, item.quantity - 1)
}
className="w-8 h-8 rounded-full border flex items-center justify-center"
>
-
</button>
<span className="w-8 text-center">{item.quantity}</span>
<button
onClick={() =>
updateQuantity(item.product.id, item.quantity + 1)
}
className="w-8 h-8 rounded-full border flex items-center justify-center"
>
+
</button>
</div>
<button
onClick={() => removeItem(item.product.id)}
className="text-red-500 hover:text-red-700"
>
✕
</button>
</div>
))}
</div>
<div className="mt-6 border-t pt-4">
<div className="flex justify-between text-xl font-bold">
<span>Total:</span>
<span>${getTotalPrice().toFixed(2)}</span>
</div>
<button className="w-full mt-4 bg-blue-600 text-white py-3 rounded-lg hover:bg-blue-700 transition-colors">
Checkout
</button>
<button
onClick={clearCart}
className="w-full mt-2 text-gray-500 hover:text-gray-700"
>
Clear cart
</button>
</div>
</>
)}
</div>
</div>
);
}Step 8: Store Slices for Complex Apps
As your app grows, you will want to split your store into slices. This keeps each piece focused and testable.
Create src/stores/slices/cart-slice.ts:
// src/stores/slices/cart-slice.ts
import type { StateCreator } from "zustand";
import type { Product, CartItem } from "@/types/cart";
export interface CartSlice {
items: CartItem[];
addItem: (product: Product) => void;
removeItem: (productId: string) => void;
clearCart: () => void;
getTotalItems: () => number;
getTotalPrice: () => number;
}
export const createCartSlice: StateCreator<CartSlice> = (set, get) => ({
items: [],
addItem: (product) =>
set((state) => {
const existing = state.items.find((i) => i.product.id === product.id);
if (existing) {
return {
items: state.items.map((i) =>
i.product.id === product.id
? { ...i, quantity: i.quantity + 1 }
: i
),
};
}
return { items: [...state.items, { product, quantity: 1 }] };
}),
removeItem: (productId) =>
set((state) => ({
items: state.items.filter((i) => i.product.id !== productId),
})),
clearCart: () => set({ items: [] }),
getTotalItems: () =>
get().items.reduce((sum, i) => sum + i.quantity, 0),
getTotalPrice: () =>
get().items.reduce((sum, i) => sum + i.product.price * i.quantity, 0),
});Create src/stores/slices/ui-slice.ts:
// src/stores/slices/ui-slice.ts
import type { StateCreator } from "zustand";
export interface UISlice {
isCartOpen: boolean;
isSearchOpen: boolean;
theme: "light" | "dark";
toggleCart: () => void;
toggleSearch: () => void;
setTheme: (theme: "light" | "dark") => void;
}
export const createUISlice: StateCreator<UISlice> = (set) => ({
isCartOpen: false,
isSearchOpen: false,
theme: "light",
toggleCart: () => set((state) => ({ isCartOpen: !state.isCartOpen })),
toggleSearch: () => set((state) => ({ isSearchOpen: !state.isSearchOpen })),
setTheme: (theme) => set({ theme }),
});Combine slices in src/stores/app-store.ts:
// src/stores/app-store.ts
import { create } from "zustand";
import { persist, createJSONStorage } from "zustand/middleware";
import { createCartSlice, type CartSlice } from "./slices/cart-slice";
import { createUISlice, type UISlice } from "./slices/ui-slice";
type AppStore = CartSlice & UISlice;
export const useAppStore = create<AppStore>()(
persist(
(...args) => ({
...createCartSlice(...args),
...createUISlice(...args),
}),
{
name: "app-storage",
storage: createJSONStorage(() => localStorage),
partialize: (state) => ({ items: state.items, theme: state.theme }),
}
)
);Now you have a clean, modular store that scales with your application.
Step 9: Devtools and Logging Middleware
Stack multiple middleware for a great development experience:
// src/stores/app-store.ts (enhanced version)
import { create } from "zustand";
import { persist, createJSONStorage, devtools } from "zustand/middleware";
import { createCartSlice, type CartSlice } from "./slices/cart-slice";
import { createUISlice, type UISlice } from "./slices/ui-slice";
type AppStore = CartSlice & UISlice;
export const useAppStore = create<AppStore>()(
devtools(
persist(
(...args) => ({
...createCartSlice(...args),
...createUISlice(...args),
}),
{
name: "app-storage",
storage: createJSONStorage(() => localStorage),
partialize: (state) => ({ items: state.items, theme: state.theme }),
}
),
{ name: "AppStore" }
)
);Install the Redux DevTools browser extension to inspect your Zustand store in real time. Every state change will appear with action names and state diffs.
Custom Logging Middleware
// src/stores/middleware/logger.ts
import type { StateCreator, StoreMutatorIdentifier } from "zustand";
type Logger = <
T,
Mps extends [StoreMutatorIdentifier, unknown][] = [],
Mcs extends [StoreMutatorIdentifier, unknown][] = []
>(
f: StateCreator<T, Mps, Mcs>,
name?: string
) => StateCreator<T, Mps, Mcs>;
type LoggerImpl = <T>(
f: StateCreator<T, [], []>,
name?: string
) => StateCreator<T, [], []>;
const loggerImpl: LoggerImpl = (f, name) => (set, get, store) => {
const loggedSet: typeof set = (...args) => {
const prev = get();
set(...(args as Parameters<typeof set>));
const next = get();
if (process.env.NODE_ENV === "development") {
console.log(`[${name ?? "store"}]`, { prev, next });
}
};
return f(loggedSet, get, store);
};
export const logger = loggerImpl as unknown as Logger;Step 10: Putting It All Together
Create the main page at src/app/page.tsx:
// src/app/page.tsx
import { ProductCard } from "@/components/product-card";
import { CartBadge } from "@/components/cart-badge";
import { CartSidebar } from "@/components/cart-sidebar";
import type { Product } from "@/types/cart";
const products: Product[] = [
{ id: "1", name: "Wireless Headphones", price: 79.99, image: "🎧", category: "Electronics" },
{ id: "2", name: "Mechanical Keyboard", price: 149.99, image: "⌨️", category: "Electronics" },
{ id: "3", name: "Running Shoes", price: 129.99, image: "👟", category: "Sports" },
{ id: "4", name: "Coffee Maker", price: 89.99, image: "☕", category: "Kitchen" },
{ id: "5", name: "Desk Lamp", price: 39.99, image: "💡", category: "Office" },
{ id: "6", name: "Backpack", price: 59.99, image: "🎒", category: "Accessories" },
];
export default function HomePage() {
return (
<div className="min-h-screen bg-gray-50">
<header className="bg-white shadow-sm sticky top-0 z-40">
<div className="max-w-7xl mx-auto px-4 py-4 flex justify-between items-center">
<h1 className="text-2xl font-bold">Zustand Store</h1>
<CartBadge />
</div>
</header>
<main className="max-w-7xl mx-auto px-4 py-8">
<h2 className="text-3xl font-bold mb-8">Products</h2>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{products.map((product) => (
<ProductCard key={product.id} product={product} />
))}
</div>
</main>
<CartSidebar />
</div>
);
}Step 11: Testing Zustand Stores
Zustand stores are plain functions, making them easy to test:
// src/stores/__tests__/cart-store.test.ts
import { describe, it, expect, beforeEach } from "vitest";
import { useCartStore } from "../cart-store";
const mockProduct = {
id: "test-1",
name: "Test Product",
price: 29.99,
image: "🧪",
category: "Test",
};
describe("Cart Store", () => {
beforeEach(() => {
// Reset store between tests
useCartStore.setState({ items: [] });
});
it("adds an item to the cart", () => {
useCartStore.getState().addItem(mockProduct);
const items = useCartStore.getState().items;
expect(items).toHaveLength(1);
expect(items[0].product.id).toBe("test-1");
expect(items[0].quantity).toBe(1);
});
it("increments quantity for existing items", () => {
useCartStore.getState().addItem(mockProduct);
useCartStore.getState().addItem(mockProduct);
const items = useCartStore.getState().items;
expect(items).toHaveLength(1);
expect(items[0].quantity).toBe(2);
});
it("removes an item from the cart", () => {
useCartStore.getState().addItem(mockProduct);
useCartStore.getState().removeItem("test-1");
expect(useCartStore.getState().items).toHaveLength(0);
});
it("calculates total price correctly", () => {
useCartStore.getState().addItem(mockProduct);
useCartStore.getState().addItem(mockProduct);
expect(useCartStore.getState().getTotalPrice()).toBe(59.98);
});
it("clears the entire cart", () => {
useCartStore.getState().addItem(mockProduct);
useCartStore.getState().clearCart();
expect(useCartStore.getState().items).toHaveLength(0);
});
});Notice how you access the store directly with getState() and setState() outside of React components. No need for render utilities or providers.
Troubleshooting
Hydration mismatch warnings
If you see hydration mismatches with persistent stores, ensure client components using persistent state show a loading skeleton until hydrated:
const hydrated = useHydration();
if (!hydrated) return <Skeleton />;Store not updating across tabs
Add the storage event listener for cross-tab synchronization:
persist(
// ... store config
{
name: "cart-storage",
storage: createJSONStorage(() => localStorage),
// This enables cross-tab sync
skipHydration: false,
}
)Performance issues with large stores
Use granular selectors instead of selecting the entire store. Use useShallow when selecting multiple values that return new object references.
Next Steps
Now that you have mastered Zustand with Next.js App Router, here are some ways to extend this further:
- Add Immer middleware for more ergonomic nested state updates
- Build a wishlist using a separate store slice
- Integrate with a real API using Zustand's async actions
- Add optimistic updates for server mutations
- Explore Zustand's
subscribeWithSelectorfor side effects outside React
Conclusion
Zustand offers the perfect balance between simplicity and power for React state management in 2026. Its minimal API, excellent TypeScript support, and seamless integration with Next.js App Router make it the ideal choice for modern applications.
You have learned how to create stores, use selectors for performance, add persistence, handle SSR hydration, organize code with slices, and test everything. These patterns scale from simple counter apps to complex e-commerce platforms.
The key takeaway: start simple with a single store, add middleware as needed, and split into slices only when complexity demands it. Zustand grows with your application without ever getting in your way.
Discuss Your Project with Us
We're here to help with your web development needs. Schedule a call to discuss your project and how we can assist you.
Let's find the best solutions for your needs.
Related Articles

Authenticate Your Next.js 15 App with Auth.js v5: Email, OAuth, and Role-Based Access
Learn how to add production-ready authentication to your Next.js 15 application using Auth.js v5. This comprehensive guide covers Google OAuth, email/password credentials, protected routes, middleware, and role-based access control.

Build a Local AI Chatbot with Ollama and Next.js: Complete Guide
Build a private, fully local AI chatbot using Ollama and Next.js. This hands-on tutorial covers installation, streaming responses, model selection, and deploying a production-ready chat interface — all without sending data to the cloud.

Building a Content-Driven Website with Payload CMS 3 and Next.js
Learn how to build a full-featured content-driven website using Payload CMS 3, which runs natively inside Next.js App Router. This tutorial covers collections, rich text editing, media uploads, authentication, and deploying to production.