Introduction to State Management in Next.js
State management is one of the most critical architectural decisions you'll make when building a Next.js application. Because Next.js blends server-side rendering (SSR), static site generation (SSG), and client-side rendering, the way you handle state is fundamentally different from a traditional single-page React application. In this tutorial, we'll explore what state management means in the context of Next.js, why it matters, the most common patterns, and the libraries that help you get the job done.
What Is State Management?
State management refers to the way an application stores, updates, and shares data across its components. In a Next.js app, state can be broken down into several categories:
- Server state: Data fetched from APIs or databases, often cached and synchronized with the server.
- Client state: UI-specific data like form inputs, modal visibility, or theme preferences.
- URL state: Data encoded in the URL, such as query parameters or route segments.
- Global state: Data shared across many components, such as user authentication status or cart contents.
Understanding which type of state you're dealing with is the first step toward choosing the right tool.
Why State Management Matters in Next.js
Next.js introduces a server component architecture, especially with the App Router. This means some components render on the server and never ship JavaScript to the browser, while others render on the client. State management tools must respect this boundary. A library that assumes everything runs in the browser will break when used inside a server component. Conversely, fetching data on the server and passing it down as props is often more efficient than managing it client-side. Choosing the right pattern improves performance, reduces bundle size, and keeps your codebase maintainable.
Pattern 1: Local Component State with useState
The simplest form of state management is local component state using React's useState hook. This is ideal for ephemeral UI state that doesn't need to be shared. Because useState is a client-only feature, you must mark the component with the "use client" directive.
"use client";
import { useState } from "react";
export default function Counter() {
const [count, setCount] = useState(0);
return (
<div>
<p>Current count: {count}</p>
<button onClick={() => setCount(count + 1)}>Increment</button>
<button onClick={() => setCount(0)}>Reset</button>
</div>
);
}
Use local state when the data is scoped to a single component and doesn't need to persist across navigation or be shared elsewhere.
Pattern 2: Lifting State Up with Props
When two or more components need access to the same state, the classic React approach is to lift the state up to their nearest common ancestor. This works well for small trees but can lead to prop drilling as the application grows.
"use client";
import { useState } from "react";
export default function SearchWidget() {
const [query, setQuery] = useState("");
return (
<div>
<SearchInput value={query} onChange={setQuery} />
<SearchResults query={query} />
</div>
);
}
function SearchInput({ value, onChange }) {
return (
<input
type="text"
value={value}
onChange={(e) => onChange(e.target.value)}
placeholder="Search..."
/>
);
}
function SearchResults({ query }) {
return <p>Showing results for: {query}</p>;
}
Prop drilling becomes painful when state must pass through many intermediate components. That's where context and external libraries come in.
Pattern 3: Context API for Global State
React's built-in Context API lets you share state across a component tree without prop drilling. In Next.js, you typically create a provider component marked as a client component and wrap part of your layout with it.
// app/providers/UserProvider.tsx
"use client";
import { createContext, useContext, useState, ReactNode } from "react";
type User = { id: string; name: string } | null;
type UserContextType = {
user: User;
setUser: (user: User) => void;
};
const UserContext = createContext<UserContextType | undefined>(undefined);
export function UserProvider({ children }: { children: ReactNode }) {
const [user, setUser] = useState<User>(null);
return (
<UserContext.Provider value={{ user, setUser }}>
{children}
</UserContext.Provider>
);
}
export function useUser() {
const context = useContext(UserContext);
if (!context) {
throw new Error("useUser must be used within a UserProvider");
}
return context;
}
Then wrap your layout with the provider:
// app/layout.tsx
import { UserProvider } from "./providers/UserProvider";
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="en">
<body>
<UserProvider>{children}</UserProvider>
</body>
</html>
);
}
Context is great for low-frequency updates like theme, locale, or authentication status. However, it can cause performance issues when state changes frequently, because every consumer re-renders on each update.
Pattern 4: Zustand for Lightweight Global State
Zustand is a popular, minimal state management library that avoids the boilerplate of Redux and the re-render issues of Context. It works seamlessly with Next.js because stores can be created outside the component tree.
// store/cartStore.ts
import { create } from "zustand";
type CartItem = {
id: string;
name: string;
price: number;
quantity: number;
};
type CartState = {
items: CartItem[];
addItem: (item: CartItem) => void;
removeItem: (id: string) => void;
clear: () => void;
total: () => number;
};
export const useCartStore = create<CartState>((set, get) => ({
items: [],
addItem: (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, quantity: i.quantity + item.quantity } : i
),
};
}
return { items: [...state.items, item] };
}),
removeItem: (id) =>
set((state) => ({
items: state.items.filter((i) => i.id !== id),
})),
clear: () => set({ items: [] }),
total: () => get().items.reduce((sum, i) => sum + i.price * i.quantity, 0),
}));
Using the store in a client component is straightforward:
"use client";
import { useCartStore } from "@/store/cartStore";
export default function CartSummary() {
const items = useCartStore((state) => state.items);
const total = useCartStore((state) => state.total());
const clear = useCartStore((state) => state.clear);
return (
<div>
<h3>Cart ({items.length} items)</h3>
<ul>
{items.map((item) => (
<li key={item.id}>
{item.name} x {item.quantity} - ${item.price * item.quantity}
</li>
))}
</ul>
<p>Total: ${total}</p>
<button onClick={clear}>Clear Cart</button>
</div>
);
}
Notice how we select only the slices of state we need. This prevents unnecessary re-renders when unrelated parts of the store change.
Pattern 5: Redux Toolkit for Complex Applications
For large applications with complex state logic, Redux Toolkit (RTK) remains a robust choice. It provides predictable state transitions, excellent DevTools, and middleware support. Here's how to set it up in a Next.js App Router project.
// lib/store.ts
import { configureStore, createSlice, PayloadAction } from "@reduxjs/toolkit";
export type CounterState = { value: number };
const initialState: CounterState = { value: 0 };
const counterSlice = createSlice({
name: "counter",
initialState,
reducers: {
increment: (state) => {
state.value += 1;
},
decrement: (state) => {
state.value -= 1;
},
incrementByAmount: (state, action: PayloadAction<number>) => {
state.value += action.payload;
},
},
});
export const { increment, decrement, incrementByAmount } = counterSlice.actions;
export const store = configureStore({
reducer: {
counter: counterSlice.reducer,
},
});
export type RootState = ReturnType<typeof store.getState>;
export type AppDispatch = typeof store.dispatch;
Create a provider component to make the store available:
// app/providers/ReduxProvider.tsx
"use client";
import { Provider } from "react-redux";
import { store } from "@/lib/store";
import { ReactNode } from "react";
export default function ReduxProvider({ children }: { children: ReactNode }) {
return <Provider store={store}>{children}</Provider>;
}
Then consume the store with typed hooks:
// lib/hooks.ts
import { useDispatch, useSelector } from "react-redux";
import type { RootState, AppDispatch } from "./store";
export const useAppDispatch = () => useDispatch<AppDispatch>();
export const useAppSelector = useSelector;
"use client";
import { useAppDispatch, useAppSelector } from "@/lib/hooks";
import { increment, decrement } from "@/lib/store";
export default function Counter() {
const count = useAppSelector((state: RootState) => state.counter.value);
const dispatch = useAppDispatch();
return (
<div>
<button onClick={() => dispatch(decrement())}>-</button>
<span>{count}</span>
<button onClick={() => dispatch(increment())}>+</button>
</div>
);
}
Pattern 6: Server State with TanStack Query
Not all state lives on the client. Much of what we call "state" is actually cached server data. TanStack Query (formerly React Query) excels at managing this kind of state, handling caching, refetching, and synchronization automatically.
// app/providers/QueryProvider.tsx
"use client";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { useState, ReactNode } from "react";
export default function QueryProvider({ children }: { children: ReactNode }) {
const [queryClient] = useState(
() =>
new QueryClient({
defaultOptions: {
queries: {
staleTime: 60 * 1000,
refetchOnWindowFocus: false,
},
},
})
);
return (
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
);
}
Using a query in a client component:
"use client";
import { useQuery } from "@tanstack/react-query";
type Post = { id: number; title: string; body: string };
async function fetchPosts(): Promise<Post[]> {
const res = await fetch("https://jsonplaceholder.typicode.com/posts");
if (!res.ok) throw new Error("Failed to fetch posts");
return res.json();
}
export default function PostsList() {
const { data, isLoading, error } = useQuery({
queryKey: ["posts"],
queryFn: fetchPosts,
});
if (isLoading) return <p>Loading posts...</p>;
if (error) return <p>Error: {(error as Error).message}</p>;
return (
<ul>
{data?.slice(0, 10).map((post) => (
<li key={post.id}>{post.title}</li>
))}
</ul>
);
}
TanStack Query shines when you have data that needs to stay fresh, supports optimistic updates, and deduplicates requests across components.
Pattern 7: URL State with next/navigation
One of the most underused patterns in Next.js is storing state in the URL. URL state is shareable, bookmarkable, and survives page refreshes. The useSearchParams and useRouter hooks from next/navigation make this easy.
"use client";
import { useSearchParams, useRouter, usePathname } from "next/navigation";
import { useCallback } from "react";
export default function FilterBar() {
const searchParams = useSearchParams();
const router = useRouter();
const pathname = usePathname();
const currentFilter = searchParams.get("filter") || "all";
const updateFilter = useCallback(
(value: string) => {
const params = new URLSearchParams(searchParams.toString());
params.set("filter", value);
router.push(`${pathname}?${params.toString()}`);
},
[searchParams, router, pathname]
);
return (
<div>
{["all", "active", "completed"].map((option) => (
<button
key={option}
onClick={() => updateFilter(option)}
style={{ fontWeight: currentFilter === option ? "bold" : "normal" }}
>
{option}
</button>
))}
<p>Active filter: {currentFilter}</p>
</div>
);
}
This pattern is perfect for filters, pagination, sorting, and tabs. It keeps your state visible and restorable without any client-side storage.
Pattern 8: Persistent State with localStorage
For state that should persist across sessions, such as theme preference or recently viewed items, localStorage is a common choice. Because localStorage is only available in the browser, you must guard against server execution.
"use client";
import { useEffect, useState } from "react";
export function useLocalStorage<T>(key: string, initialValue: T) {
const [value, setValue] = useState<T>(initialValue);
useEffect(() => {
try {
const stored = window.localStorage.getItem(key);
if (stored) {
setValue(JSON.parse(stored));
}
} catch (e) {
console.warn("Failed to read localStorage", e);
}
}, [key]);
useEffect(() => {
try {
window.localStorage.setItem(key, JSON.stringify(value));
} catch (e) {
console.warn("Failed to write localStorage", e);
}
}, [key, value]);
return [value, setValue] as const;
}
Usage example for a theme toggle:
"use client";
import { useLocalStorage } from "@/hooks/useLocalStorage";
export default function ThemeToggle() {
const [theme, setTheme] = useLocalStorage("theme", "light");
return (
<button onClick={() => setTheme(theme === "light" ? "dark" : "light")}>
Current theme: {theme} (click to toggle)
</button>
);
}
Best Practices for State Management in Next.js
Keep State as Close to Where It's Needed as Possible
Don't reach for a global store by default. If state is only used by one component, keep it local. If it's shared between a parent and a few children, lift it up. Only use global state when data truly needs to be accessible from many disconnected parts of the application.
Respect the Server/Client Boundary
Server components cannot use hooks like useState, useEffect, or context providers. Fetch data on the server when possible and pass it as props. Reserve client state for interactivity that can't happen on the server.
Separate Server State from Client State
Don't duplicate server data into a global client store unless you have a specific reason. Tools like TanStack Query are designed to manage server state with caching and invalidation, which is more reliable than manually syncing fetched data into Redux or Zustand.
Use Selectors to Prevent Unnecessary Re-renders
When using Zustand or Redux, always select the minimal slice of state a component needs. Subscribing to the entire store causes the component to re-render on every change, even unrelated ones.
Hydrate Client Stores from the Server
If you need initial data in a client store, fetch it on the server and pass it as props to a provider that initializes the store. This avoids a flash of empty content and reduces client-side fetching.
// app/providers/HydratedZustandProvider.tsx
"use client";
import { useEffect, useRef } from "react";
import { useCartStore, CartItem } from "@/store/cartStore";
export default function HydratedZustandProvider({
children,
initialItems,
}: {
children: React.ReactNode;
initialItems: CartItem[];
}) {
const hydrated = useRef(false);
useEffect(() => {
if (!hydrated.current) {
useCartStore.setState({ items: initialItems });
hydrated.current = true;
}
}, [initialItems]);
return <>{children}</>;
}
Choose the Right Tool for the Job
- Local UI state:
useStateoruseReducer - Shared UI state across a subtree: Context API
- Global client state: Zustand or Redux Toolkit
- Server data with caching: TanStack Query or SWR
- Shareable, bookmarkable state: URL search params
- Persistent client state: localStorage with a custom hook
Conclusion
State management in Next.js is not a one-size-fits-all problem. The framework's hybrid rendering model means you must think carefully about where state lives, how it flows, and whether it belongs on the server or the client. By understanding the different categories of state and matching them to the appropriate tool—whether that's local hooks, Context, Zustand, Redux Toolkit, TanStack Query, or URL parameters—you can build applications that are performant, maintainable, and predictable. Start simple, add complexity only when you feel the pain, and always let the nature of the data guide your architectural choices.