Zustand in Next.js: Simple Client State Management
When building a Next.js application, state management can become confusing pretty quickly.
Next.js gives us Server Components, Client Components, Server Actions, URL state, cookies, and several other ways to manage data.
Then comes another question:
Where should client-side state live?
For simple local state, React's useState is usually enough.
But when multiple components need access to the same client-side state, Zustand can be a very clean solution.
In this article, we'll look at how to use Zustand with Next.js App Router, including a practical example.
What is Zustand?
Zustand is a small state-management library for React.
Instead of creating reducers, actions, providers, and a lot of boilerplate, you can create a store containing your state and the functions that update it.
For example:
import { create } from "zustand";
type CounterStore = {
count: number;
increment: () => void;
decrement: () => void;
};
export const useCounterStore = create<CounterStore>((set) => ({
count: 0,
increment: () =>
set((state) => ({
count: state.count + 1,
})),
decrement: () =>
set((state) => ({
count: state.count - 1,
})),
}));
Then any Client Component can access the store.
Installing Zustand in Next.js
First install Zustand:
npm install zustand
With pnpm:
pnpm add zustand
Or Yarn:
yarn add zustand
Creating a Store
Let's create a simple cart store.
A possible project structure:
src/
├── app/
├── components/
└── stores/
└── cart-store.ts
Inside cart-store.ts:
import { create } from "zustand";
type Product = {
id: string;
name: string;
price: number;
};
type CartItem = Product & {
quantity: number;
};
type CartStore = {
items: CartItem[];
addItem: (product: Product) => void;
removeItem: (productId: string) => void;
clearCart: () => void;
};
export const useCartStore = create<CartStore>((set) => ({
items: [],
addItem: (product) =>
set((state) => {
const existingItem = state.items.find(
(item) => item.id === product.id
);
if (existingItem) {
return {
items: state.items.map((item) =>
item.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.id !== productId
),
})),
clearCart: () => set({ items: [] }),
}));
Now we have a global client-side store.
The Important Part: Client Components
This is where Next.js developers need to pay attention.
Zustand uses React hooks.
Therefore, components that directly use a Zustand store need to be Client Components.
Add:
"use client";
at the top of the component.
For example:
"use client";
import { useCartStore } from "@/stores/cart-store";
export default function CartButton() {
const items = useCartStore((state) => state.items);
return (
<button>
Cart ({items.length})
</button>
);
}
Without "use client", you'll run into problems because Server Components cannot use client-side React hooks like this.
Using the Store in a Product Component
Suppose we have a product card:
"use client";
import { useCartStore } from "@/stores/cart-store";
type ProductCardProps = {
product: {
id: string;
name: string;
price: number;
};
};
export default function ProductCard({
product,
}: ProductCardProps) {
const addItem = useCartStore(
(state) => state.addItem
);
return (
<article>
<h2>{product.name}</h2>
<p>${product.price}</p>
<button
onClick={() => addItem(product)}
>
Add to Cart
</button>
</article>
);
}
Now the product component doesn't need to pass the cart state anywhere.
It simply calls:
addItem(product);
Reading the Store in Another Component
Now let's create a cart component.
"use client";
import { useCartStore } from "@/stores/cart-store";
export default function Cart() {
const items = useCartStore(
(state) => state.items
);
const removeItem = useCartStore(
(state) => state.removeItem
);
return (
<div>
{items.map((item) => (
<div key={item.id}>
<span>
{item.name} × {item.quantity}
</span>
<button
onClick={() => removeItem(item.id)}
>
Remove
</button>
</div>
))}
</div>
);
}
The product card and cart are completely separate components.
But they share the same state.
That's where Zustand becomes useful.
Use Selectors
You might see developers write:
const store = useCartStore();
But I prefer selecting exactly what the component needs.
For example:
const items = useCartStore(
(state) => state.items
);
Or:
const addItem = useCartStore(
(state) => state.addItem
);
This makes the component's dependency on the store explicit and allows Zustand to subscribe to the selected state.
For larger applications, being intentional about selectors becomes increasingly useful.
Zustand and Server Components
This is one of the most important concepts when using Zustand with modern Next.js.
Next.js App Router separates your application into:
Server Components
+
Client Components
Zustand is primarily useful for client-side state.
For example:
Server Component
↓
Fetch products
↓
Pass products
↓
Client Component
↓
Zustand
↓
Interactive client state
A Server Component can fetch data:
export default async function ProductsPage() {
const products = await getProducts();
return (
<ProductList products={products} />
);
}
Then the interactive component can use Zustand:
"use client";
import { useCartStore } from "@/stores/cart-store";
export function ProductList({ products }) {
const addItem = useCartStore(
(state) => state.addItem
);
// ...
}
This separation is important.
Don't Put Everything in Zustand
One of the easiest mistakes is putting all application data into a global Zustand store.
For example, imagine your application fetches:
Products
Orders
Users
Notifications
Invoices
Reports
You don't necessarily need to put all of that into Zustand.
There is a difference between client state and server state.
Client state
Things like:
Sidebar open/closed
Modal state
Theme
Cart
Filters
User preferences
Temporary UI state
Zustand can be a great fit.
Server state
Things like:
Products from API
Orders from database
User profile
Notifications
Analytics
For these, a server-state library such as TanStack Query can often be more appropriate.
You can use both together.
Zustand + TanStack Query
For example:
Next.js
│
├── Server Components
│ └── Initial server data
│
├── TanStack Query
│ └── Server/API state
│
└── Zustand
└── Client/UI state
This separation can make your application easier to reason about.
For example, a product API response doesn't necessarily belong in Zustand just because a component needs to access it.
Persisting Zustand State
Another useful feature is persistence.
For example, you may want a cart to survive a page refresh.
Zustand provides the persist middleware.
import { create } from "zustand";
import { persist } from "zustand/middleware";
type CartStore = {
items: string[];
addItem: (id: string) => void;
};
export const useCartStore = create<CartStore>()(
persist(
(set) => ({
items: [],
addItem: (id) =>
set((state) => ({
items: [...state.items, id],
})),
}),
{
name: "cart-storage",
}
)
);
Now Zustand can persist the store using browser storage.
This can be useful for:
- Shopping carts
- Theme preferences
- User preferences
- Drafts
- Onboarding progress
But be careful about storing sensitive information in browser storage.
A Small UI Store
Zustand isn't only useful for complex data.
It can also be useful for shared UI state.
For example:
import { create } from "zustand";
type UIStore = {
sidebarOpen: boolean;
toggleSidebar: () => void;
closeSidebar: () => void;
};
export const useUIStore = create<UIStore>((set) => ({
sidebarOpen: false,
toggleSidebar: () =>
set((state) => ({
sidebarOpen: !state.sidebarOpen,
})),
closeSidebar: () =>
set({
sidebarOpen: false,
}),
}));
Then:
"use client";
import { useUIStore } from "@/stores/ui-store";
export function SidebarButton() {
const toggleSidebar = useUIStore(
(state) => state.toggleSidebar
);
return (
<button onClick={toggleSidebar}>
Menu
</button>
);
}
And somewhere else:
"use client";
import { useUIStore } from "@/stores/ui-store";
export function Sidebar() {
const sidebarOpen = useUIStore(
(state) => state.sidebarOpen
);
if (!sidebarOpen) return null;
return <aside>Sidebar</aside>;
}
Two completely different components can now coordinate their UI state without prop drilling.
Where Should Zustand Stores Live?
There isn't one mandatory folder structure.
For a smaller project:
src/
└── stores/
├── cart-store.ts
├── auth-store.ts
└── ui-store.ts
For a larger application, feature-based organization can make more sense:
src/
└── features/
├── cart/
│ ├── components/
│ └── store/
│ └── cart-store.ts
│
├── auth/
│ ├── components/
│ └── store/
│ └── auth-store.ts
│
└── dashboard/
├── components/
└── store/
└── dashboard-store.ts
The important thing is not the folder name.
The important thing is keeping state close to the feature that owns it when the application becomes large.
When Should You Use Zustand in Next.js?
I wouldn't install Zustand in every Next.js project automatically.
Start with React's built-in state.
Use useState when the state belongs to one component.
Use URL parameters when the state should be shareable/bookmarkable, such as:
/products?category=shoes&sort=price
Use server-side mechanisms when the state belongs to the server.
And consider Zustand when multiple client components genuinely need shared state.
A simple rule is:
Local UI state
↓
useState
URL/shareable state
↓
URL search params
Server/API state
↓
Server Components / TanStack Query
Shared client state
↓
Zustand
It isn't a strict rule, but it's a useful mental model.
Final Thoughts
Zustand isn't interesting because it can replace every other state-management solution.
It's interesting because it doesn't try to make state management more complicated than it needs to be.
With Next.js App Router, the important part is understanding where your state belongs.
Not everything needs to be global.
Not everything needs Zustand.
Not everything needs a client component.
Once you understand that distinction, Zustand becomes a very simple tool:
Create a store → select what you need → update the state → let React handle the UI.
And for many Next.js applications, that's more than enough.