A lot of Next.js apps get into trouble the same way.
They start clean. A few useState hooks handle a modal, a search box, maybe a selected tab. Then product asks for saved filters, optimistic updates, a dashboard, auth-aware navigation, background refresh, and some shared UI preferences. Suddenly the app has root-level providers, duplicated fetches, props crossing five layers, and client components in places that should never have been client components.
That is where most next js state management advice gets less helpful. You search for solutions and get a parade of library tutorials. Redux. Zustand. Context. Jotai. TanStack Query. Each one looks reasonable in isolation. None of them give you the core mental model for the App Router.
The App Router changed the default answer. In older React apps, the safe assumption was often “put it on the client and coordinate from there.” In modern Next.js, that assumption is expensive. You pay in bundle size, hydration work, and architectural drift.
The pattern that holds up in production is simpler: treat the server as the default home for data, keep client state narrow, and only introduce a client library when the built-in model stops being enough. That sounds obvious. In practice, it changes almost every decision you make.
Untangling the Web of Next js State
A common failure mode looks like this.
A team adds a UserProvider to the root layout so every component can “just access the user.” Then they add a DashboardProvider for filters, a NotificationsProvider for toasts and unread counts, and a SettingsProvider for theme and feature flags. By the time they ship the second major feature, half the tree is marked 'use client'.
The app still works. It just feels heavy.
Pages that should stream quickly now wait on client hydration. Components that only needed server-fetched data now sit behind providers. Debugging gets harder because the source of truth is no longer obvious. Is the data coming from the database, a fetch in useEffect, a context value, or a stale store snapshot?
I have seen this happen most often when teams treat all state as one thing. It is not. The modal’s open flag is not the same kind of state as a user session. A search query synced to the URL is not the same thing as a cached API response. If you lump them together, the architecture gets messy fast.
The fix is not “pick the best library.” The fix is to sort state into the right bucket first.
Practical rule: if a value can be fetched or derived on the server, start there. Move it to the client only when interaction, latency hiding, or browser-only behavior requires it.
That one shift removes a surprising amount of complexity. It also lines up with what the App Router was built to do.
The Three Categories of State in Modern Next js
The easiest way to reason about next js state management is to split state into three categories. I use a restaurant analogy because it forces clean boundaries.
Local UI state
This is one diner deciding whether to expand the dessert menu.
A tooltip is open. A form field is focused. A modal is visible. A tab is selected. None of that belongs in a global store. None of it belongs on the server. It is local UI state, and React’s built-in hooks are usually enough.
Examples:
- Short-lived interactions:
useStatefor open or closed UI, selected rows, active tab, wizard step. - Component-specific form behavior: validation errors, dirty flags, input masks.
- Animation and interaction state: hover intent, drag state, transition flags.
This state should stay close to the component that owns it. If three sibling components need it, lift it one level. If twenty components need it, stop and ask whether it is client-global or whether the design can be split differently.
Shared client state
This is the front-of-house staff coordinating service.
A theme toggle affects multiple components. A command palette needs to open from different parts of the tree. A client-side cart drawer may need to respond instantly before the server catches up. This is shared client state.
Typical examples:
- Theme preference
- Sidebar collapsed state
- Multi-step form state across route-adjacent client components
- Temporary optimistic UI before a mutation settles
- Browser-only integrations like localStorage-backed preferences
This category is where Context, Zustand, Jotai, or Redux can help. But this is also where teams overreach. Just because many components need a value does not mean a root-level client provider is the right answer.
Server cache state
This is the kitchen’s inventory and order board.
Products, invoices, dashboards, user accounts, permissions, reports, notifications, and search results from your backend are not “frontend state” in the usual sense. They are server state, and in the App Router they should usually be fetched in Server Components and cached with Next.js features.
This includes:
- Database-backed content
- Authenticated server-fetched user data
- CMS content
- Reporting data
- Search results that can be expressed in the URL
- Any data that many users or routes consume predictably
The server-first mindset works because it puts the source of truth where it already lives. You avoid inventing a second client-side state layer just to mirror backend data.
A quick classification test
When deciding where a piece of state belongs, ask these questions in order:
Does the browser uniquely own it?
If yes, it is probably local UI state or shared client state.Does the server already own it?
If yes, fetch it in a Server Component or mutate it with a Server Action.Must it survive navigation or be shareable by URL?
If yes, considersearchParamsbefore a store.Do multiple unrelated client components need to update it interactively?
If yes, a client store may be justified.
The mistake to avoid
The biggest mistake is turning server cache state into shared client state too early.
That usually happens when developers fetch in useEffect, store the result in Context, and then add more hooks around it. The app becomes harder to cache, harder to stream, and harder to reason about. In App Router apps, that should be the exception, not the baseline.
Mastering Server State with App Router Features
A common App Router failure looks like this. A page loads, a client component fires useEffect, data flashes in late, the same response gets copied into Context, and every mutation turns into a manual refetch problem. That pattern worked tolerably in older React apps. In a modern Next.js app, it usually means server-owned data was pushed to the browser too early.
The default should be simpler. If the server already owns the data, fetch it in a Server Component, render it on the server, and let Next.js handle caching and revalidation. That is the center of the Next.js App Router architecture, and it changes how state decisions should be made.
Here is the baseline pattern.
Fetch on the server first
// app/dashboard/page.tsx
import { cookies } from 'next/headers'
async function getDashboardData() {
const session = cookies().get('session')?.value
const res = await fetch('https://api.example.com/dashboard', {
headers: {
Cookie: `session=${session ?? ''}`,
},
cache: 'force-cache',
})
if (!res.ok) {
throw new Error('Failed to load dashboard')
}
return res.json()
}
export default async function DashboardPage() {
const data = await getDashboardData()
return (
<main>
<h1>Dashboard</h1>
<pre>{JSON.stringify(data, null, 2)}</pre>
</main>
)
}
This keeps the request on the server, avoids a browser-side waterfall, and makes the ownership model obvious. The backend remains the source of truth. The page just renders it.
That sounds small. In production, it is a large shift. Pages become easier to cache, auth stays closer to the request, and fewer components need to hydrate.
Use isolated client components for interaction
Client components still matter. The goal is to keep them focused on interaction, not data ownership.
// app/dashboard/page.tsx
import FiltersPanel from './filters-panel'
async function getProjects() {
const res = await fetch('https://api.example.com/projects', {
cache: 'force-cache',
})
if (!res.ok) throw new Error('Failed to load projects')
return res.json()
}
export default async function DashboardPage() {
const projects = await getProjects()
return (
<>
<h1>Projects</h1>
<FiltersPanel initialProjects={projects} />
</>
)
}
// app/dashboard/filters-panel.tsx
'use client'
import { useMemo, useState } from 'react'
export default function FiltersPanel({ initialProjects }: { initialProjects: any[] }) {
const [query, setQuery] = useState('')
const filtered = useMemo(() => {
return initialProjects.filter((project) =>
project.name.toLowerCase().includes(query.toLowerCase())
)
}, [initialProjects, query])
return (
<section>
<input
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder="Filter projects"
/>
<ul>
{filtered.map((project) => (
<li key={project.id}>{project.name}</li>
))}
</ul>
</section>
)
}
This boundary is the pattern to keep returning to. The server fetches the list. The client handles the filter box. If filtering needs to be shareable, move the query into searchParams and let the server re-render from the URL instead of growing a client store.
Mutate with Server Actions
Writes are where the server-first model starts paying for itself.
A lot of teams still build App Router mutations like an old SPA. Submit from the client, call an API route, update local state, then patch over stale UI with another fetch. Server Actions cut out much of that plumbing. You run the mutation on the server and revalidate the affected route.
A production-friendly pattern looks like this:
// app/dashboard/actions.ts
'use server'
import { revalidatePath } from 'next/cache'
export async function updateUser(formData: FormData) {
const name = formData.get('name')
await fetch('https://api.example.com/user', {
method: 'POST',
body: JSON.stringify({ name }),
headers: {
'Content-Type': 'application/json',
},
})
revalidatePath('/dashboard')
}
// app/dashboard/profile-form.tsx
'use client'
import { useTransition } from 'react'
import { useRouter } from 'next/navigation'
import { updateUser } from './actions'
export default function ProfileForm() {
const [isPending, startTransition] = useTransition()
const router = useRouter()
return (
<form
action={(formData) => {
startTransition(async () => {
await updateUser(formData)
router.refresh()
})
}}
>
<input name="name" placeholder="Your name" />
<button disabled={isPending}>
{isPending ? 'Saving...' : 'Save'}
</button>
</form>
)
}
revalidatePath('/dashboard') clears the stale server result for that route. router.refresh() asks the client to request the fresh server render. You do not need to keep a duplicate copy of the profile in Context just to show the latest name.
There is a trade-off. Server Actions simplify write flows, but they are not a full replacement for every API design. If multiple clients outside your Next.js app need the same mutation, a normal HTTP API still belongs in the system. Server Actions are best when your Next.js app is the primary consumer and you want the UI update path to stay close to the server render path.
Know when to cache and when not to
Caching is where App Router apps either feel fast and predictable or confusing and stale.
Use built-in caching when the data is shared, relatively stable, or expensive to fetch. Product catalogs, CMS pages, dashboard summaries, and reference data usually fit well. In those cases, fetch(..., { cache: 'force-cache' }) or time-based revalidation gives you a clean default.
Skip aggressive caching when freshness matters more than reuse. User-specific account data, permission-sensitive responses, and rapidly changing operational screens often need dynamic rendering or explicit revalidation after writes.
The practical question is simple. If a user changes this data, how quickly do they expect to see the new version, and who else might reuse the old result safely? That answer should drive the cache policy.
A production rule of thumb
If a state problem can be solved by one of these, use them first:
- Server Component data fetching
searchParamsfor shareable UI state- Server Actions for writes
revalidatePathorrevalidateTagfor freshness- A small client island for interaction
Reach for a client state library after those options stop fitting. In large App Router codebases, that usually happens for interaction-heavy flows, optimistic UIs with lots of local transitions, or complex browser-only workflows such as drag-and-drop builders.
That is the server-first mindset in practice. Fewer client stores. Fewer duplicated fetches. Clearer ownership. The result is not just less code. It is a state model that matches how Next.js works now.
Choosing Your Client State Management Library
A common App Router failure mode looks like this. A team moves to Next.js 14, keeps the old Pages Router habit of putting everything in a global client store, and then wonders why bundle size grows, providers spread through the root layout, and the same data exists in three places. The fix is usually smaller than expected. Keep the server responsible for server state, then choose a client library only for state the browser owns.

If you want a broader market view before picking a tool, this roundup of the top React state management libraries is a useful companion to the framework below.
The shortlist that matters
For most App Router codebases, five options cover nearly every legitimate client-side case:
- React Context API
- Zustand
- Redux Toolkit
- Jotai
- TanStack Query
The main decision is not which one is most popular. The decision is which one solves the remaining browser-side problem without pulling server concerns back into the client.
What each tool is good at
React Context API
Context is fine for stable values with low write frequency. Theme, locale, feature flags that were already resolved elsewhere, and a thin auth presentation layer all fit.
It becomes expensive when teams use it as a general data store. In the App Router, a high-level provider can widen the client boundary and make more of the tree hydrate than necessary. Keep providers narrow and place them as deep as possible.
Best fit:
- Theme
- Locale
- UI settings with infrequent updates
- Dependency injection for a contained client subtree
Poor fit:
- Frequently changing shared state
- Server data caching
- Large shared objects consumed across many components
Zustand
Zustand is usually the first library I reach for when local component state stops being enough, but Redux would be excessive. It is a good fit for shared UI state that belongs to the browser, not the server.
Examples include sidebar state, command palette visibility, unsaved filters in a client-heavy view, and multi-step draft state that spans several components. It stays productive because the API is small, and selectors let components subscribe to only what they need. For larger trees, careful selector usage matters. Without it, a tiny store can still trigger broad re-renders.
Best fit:
- UI preferences
- Overlay and modal coordination
- Draft client state shared across a small area
- Product teams that want low ceremony
Poor fit:
- Per-request server data
- Complex business flows that benefit from reducers and explicit actions
- Domains where audit history matters
Redux Toolkit
Redux Toolkit still makes sense in large products with strict state transitions, complicated business rules, and many contributors touching the same workflows. It adds structure, and that structure is sometimes worth the cost.
I would use it for a trading workflow, policy builder, or enterprise admin surface where every action needs to be predictable and easy to inspect. I would not use it for toasts, tabs, or simple UI toggles. In App Router projects, Redux is usually strongest when the complexity is client-driven, not when it is compensating for server data that should have stayed on the server.
Best fit:
- Large product areas with many contributors
- Complex flows with explicit transitions
- Domains that benefit from reducer discipline
- Teams already comfortable with Redux patterns
Poor fit:
- Small apps
- Simple UI state
- Cases where most of the data lifecycle belongs to Server Components and Actions
Jotai
Jotai works well for fine-grained state with isolated subscriptions. Teams that like composing small state units often move quickly with it, especially in interaction-heavy interfaces.
The trade-off is consistency at scale. Jotai can feel elegant in the hands of a team that shares the same mental model, but it is less opinionated than Redux Toolkit and less familiar to many teams than Zustand. That makes code review and onboarding a little harder in some organizations.
Best fit:
- Fine-grained client state
- Interaction-heavy UIs with many isolated pieces
- Teams comfortable with atom-based composition
Poor fit:
- Teams that need stronger conventions
- Codebases where minimizing architectural variation matters more than subscription granularity
TanStack Query
TanStack Query solves a different problem. It is for async client state after the server-first path has already been exhausted.
Use it when the browser needs to refetch in the background, poll, retry, coordinate mutations, or show optimistic updates in interactive areas. Search screens, real-time dashboards, inbox-style UIs, and collaborative tools are common examples. It should complement Server Components, not replace them. If the first render can come from the server cleanly, do that first and let TanStack Query take over only where live client behavior adds real value.
As noted earlier, one common production pattern is simple: Server Components handle initial data and cache-friendly reads, while TanStack Query manages browser-side revalidation and mutation flows in the interactive parts of the page.
Best fit:
- Optimistic updates
- Background refetching
- Mutation lifecycle handling
- Interactive views with repeated client-side refresh needs
Poor fit:
- Static or mostly static pages
- Fetch-once data the server can render directly
- Cases where adding a client cache just duplicates server ownership
Comparison table
| Library | Best For | Bundle Size | Boilerplate | App Router Friendliness |
|---|---|---|---|---|
| Context API | Small global UI concerns | Low | Low at first, awkward if overused | Good when kept deep in the tree |
| Zustand | Lightweight shared client state | Low | Low | Strong for browser-owned UI state |
| Redux Toolkit | Complex business logic and large teams | Higher | Higher | Good, but easy to apply too broadly |
| Jotai | Fine-grained atomic state | Low to moderate | Moderate | Good for isolated client islands |
| TanStack Query | Async client state and mutations | Moderate | Moderate | Strong when paired with server-rendered initial data |
A practical decision framework
Use this sequence in real projects:
- Use no library for local interaction inside one component or one client island.
- Use Context for a stable value shared by a limited subtree.
- Use Zustand for shared browser state that changes often enough to make Context awkward.
- Use Redux Toolkit when the client-side workflow has real business complexity and benefits from strict transitions.
- Use TanStack Query when the browser must manage async freshness, mutation state, retries, or optimistic UX.
The App Router changed the default. Client state is now the exception, not the starting point. The best library choice is often the smallest one, and in plenty of routes, that choice is no library at all.
Practical State Management Patterns and Code
Applying the App Router model to production code makes the trade-offs obvious. Keep server-owned data on the server. Use client state for UI that the browser owns.
One mistake shows up over and over in large Next.js codebases. A root client provider gets added early for auth, filters, or fetched data, then half the tree inherits 'use client' by accident. Bundle size grows, caching gets harder to reason about, and request-scoped data starts behaving like a long-lived browser store. I avoid that by starting with Server Components and Server Actions, then adding client state only where the interaction demands it.
If your app talks to a GraphQL API, the same boundary rules still apply. Server-render the initial result, then add client state only for browser-driven interactions such as optimistic updates or polling. This guide on fetching GraphQL data in Next.js maps cleanly to that approach.
Auth state without a global auth store
Session truth belongs to the server. The client usually needs a small, display-friendly snapshot.
// app/layout.tsx
import { cookies } from 'next/headers'
import HeaderClient from './header-client'
async function getSessionUser() {
const token = cookies().get('session')?.value
if (!token) return null
const res = await fetch('https://api.example.com/me', {
headers: {
Cookie: `session=${token}`,
},
cache: 'no-store',
})
if (!res.ok) return null
return res.json()
}
export default async function RootLayout({
children,
}: {
children: React.ReactNode
}) {
const user = await getSessionUser()
return (
<html lang="en">
<body>
<HeaderClient user={user ? { id: user.id, name: user.name } : null} />
{children}
</body>
</html>
)
}
// app/header-client.tsx
'use client'
export default function HeaderClient({
user,
}: {
user: { id: string; name: string } | null
}) {
return (
<header>
{user ? <span>Signed in as {user.name}</span> : <a href="/login">Login</a>}
</header>
)
}
This pattern keeps auth request-aware. It also avoids a common failure mode where a client auth store gets out of sync after refresh, sign-out in another tab, or cookie expiry.
Theme switching with Zustand
Theme is browser-owned state. It changes instantly, often persists to local storage, and may be read by several client components. That makes it a reasonable fit for a small store.
// app/theme-store.ts
'use client'
import { create } from 'zustand'
import { persist, subscribeWithSelector } from 'zustand/middleware'
type Theme = 'light' | 'dark'
type ThemeState = {
theme: Theme
setTheme: (theme: Theme) => void
toggleTheme: () => void
}
export const useThemeStore = create<ThemeState>()(
subscribeWithSelector(
persist(
(set, get) => ({
theme: 'light',
setTheme: (theme) => set({ theme }),
toggleTheme: () =>
set({ theme: get().theme === 'light' ? 'dark' : 'light' }),
}),
{ name: 'theme-preference' }
)
)
)
// app/theme-toggle.tsx
'use client'
import { useThemeStore } from './theme-store'
export default function ThemeToggle() {
const theme = useThemeStore((state) => state.theme)
const toggleTheme = useThemeStore((state) => state.toggleTheme)
return (
<button onClick={toggleTheme}>
Current theme: {theme}
</button>
)
}
I would not use a store for a button that only affects one component. I would use one when the preference is shared across a client island and needs to survive navigation.
Form state without rerendering everything
Forms are a good place to stay boring. Keep state local until you have a clear cross-component requirement.
'use client'
import { useState } from 'react'
export default function ProfileEditor() {
const [form, setForm] = useState({
firstName: '',
lastName: '',
bio: '',
})
function updateField<K extends keyof typeof form>(key: K, value: (typeof form)[K]) {
setForm((prev) => ({ ...prev, [key]: value }))
}
return (
<form className="space-y-4">
<input
value={form.firstName}
onChange={(e) => updateField('firstName', e.target.value)}
placeholder="First name"
/>
<input
value={form.lastName}
onChange={(e) => updateField('lastName', e.target.value)}
placeholder="Last name"
/>
<textarea
value={form.bio}
onChange={(e) => updateField('bio', e.target.value)}
placeholder="Bio"
/>
</form>
)
}
For many forms, local state plus a Server Action is enough. You do not need a global form store unless multiple distant components edit or validate the same draft.
Optimistic updates with TanStack Query and a Server Action
TanStack Query earns its cost on screens with frequent mutations, refetching, retry rules, or optimistic UI. A project list, inbox, or dashboard fits that profile. A static marketing page does not.
// app/projects/query-client.tsx
'use client'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { useState } from 'react'
export function Providers({ children }: { children: React.ReactNode }) {
const [queryClient] = useState(
() =>
new QueryClient({
defaultOptions: {
queries: {
staleTime: 5 * 60 * 1000,
gcTime: 10 * 60 * 1000,
},
},
})
)
return <QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
}
// app/projects/use-projects.ts
'use client'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
async function fetchProjects() {
const res = await fetch('/api/projects')
if (!res.ok) throw new Error('Failed to fetch projects')
return res.json()
}
async function createProject(input: { name: string }) {
const res = await fetch('/api/projects', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(input),
})
if (!res.ok) throw new Error('Failed to create project')
return res.json()
}
export function useProjects() {
return useQuery({
queryKey: ['projects'],
queryFn: fetchProjects,
})
}
export function useCreateProject() {
const queryClient = useQueryClient()
return useMutation({
mutationFn: createProject,
onMutate: async (newProject) => {
await queryClient.cancelQueries({ queryKey: ['projects'] })
const previous = queryClient.getQueryData<any[]>(['projects']) ?? []
queryClient.setQueryData(['projects'], [
...previous,
{ id: 'temp-id', name: newProject.name },
])
return { previous }
},
onError: (_error, _variables, context) => {
queryClient.setQueryData(['projects'], context?.previous ?? [])
},
onSettled: () => {
queryClient.invalidateQueries({ queryKey: ['projects'] })
},
})
}
The important production choice is scope. Put the QueryClientProvider around the feature that needs it if possible, not around the entire app by reflex. That keeps the client boundary smaller and preserves the default App Router behavior everywhere else.
Use the smallest tool that matches the state owner. Server data stays with Server Components, cache tags, and Server Actions. Browser-owned interaction gets local state or a focused client store. That split keeps large Next.js apps easier to reason about months later.
Advanced Considerations for Production Apps
State management problems in production rarely announce themselves as “state management problems.” They show up as hydration errors, janky updates, stale data, or user-specific bugs that only happen under load.
Hydration mismatches usually come from blurred boundaries
Hydration issues often happen when server-rendered output and client-rendered output disagree.
Common causes:
- Browser-only APIs during render: reading
window,localStorage, ormatchMediatoo early - Non-deterministic rendering:
Date.now(), random IDs, locale-sensitive output without control - Confused ownership: rendering server data from one source and client state from another source that disagrees on first paint
The fix is usually architectural, not cosmetic. Keep server-owned data in Server Components. Delay browser-only reads until the client is mounted. Avoid making one component answer to two competing sources of truth.
Performance work starts with client boundaries
When a Next.js app feels heavy, check how much of the tree became client-side by accident.
A practical audit checklist:
- Inspect
'use client'spread: look for root layouts and shared wrappers that forced large subtrees into the client. - Review provider placement: push providers lower where possible.
- Check repeated selectors: if a store drives many components, make sure subscriptions are narrow.
- Measure bundle impact: use the Next.js bundle analyzer to see which client dependencies moved into shared chunks.
Memoization is not a cure-all
React.memo, useMemo, and useCallback are useful when you already understand the rerender path. They are not a substitute for good state placement.
Use them when:
- a child is expensive to render
- props are stable enough to benefit
- a callback identity causes downstream work
Do not use them to paper over a store or provider that updates too much of the tree.
Test the boundaries, not just the functions
For state-heavy features, the most valuable tests usually sit at the edges.
Test examples:
- Unit test store logic: actions, selectors, and reducers
- Integration test server-action flows: submit form, revalidate, confirm refreshed UI
- Render tests for hydration-sensitive components: confirm client-only logic does not alter first paint unexpectedly
Migrating from Pages Router habits
Teams coming from the Pages Router often carry over patterns that no longer fit.
Watch for these migration smells:
- Fetching page data in
useEffectjust because that used to be common - Building large root providers to replace what should now be request-aware server data
- Treating API routes as mandatory for every mutation even when Server Actions are a better fit
The migration path is usually incremental. Move one feature at a time. Start by fetching on the server, then shrink client providers, then isolate client state.
Next js State Management FAQ
Should I use Zustand or Redux for user-specific request data in the App Router
No. Using global stores like Zustand or Redux for per-request data in the App Router risks leaking data between concurrent renders.
That failure mode is documented in the Next.js App Router state-sharing discussion, which cites Vercel benchmarks showing error rates as high as 25% when teams handle concurrent user data incorrectly with shared global state. Keep request data on the server, read it from cookies() or your session layer, and pass only the serialized result the client needs.
Zustand and Redux still have a place. They work well for browser-owned state such as a wizard step, sidebar state, pending form UI, or cross-component interactions that exist only after hydration.
What is the source of truth for authentication state
The server.
Authentication state comes from cookies, sessions, and backend validation. The client can hold a snapshot for rendering, but that snapshot is only a convenience. If the browser says "logged in" and the server says "session expired," treat the session as expired and rerender from fresh server data.
How do I decide whether state belongs on the client
Start with one question. Does this state need the browser to exist?
Use this rule set:
- Keep it on the server if it comes from your database, session, headers, cookies, or any backend call.
- Keep it local in a client component if it only drives UI interaction such as open panels, form drafts, or tab selection.
- Share client state only if multiple hydrated components need to coordinate in real time.
- Put it in the URL if users should be able to refresh, bookmark, share, or use back and forward navigation without losing it.
In App Router projects, the default choice is the server. Client state is the exception, not the baseline.
When should I use TanStack Query in a Next.js App Router app
Use TanStack Query when the browser has ongoing async state to manage after the initial render is already server-driven.
Typical cases are optimistic updates, polling, background refetching, infinite scroll, and highly interactive dashboards where stale data policy matters on the client. If a route can fetch on the server, mutate with a Server Action, and refresh with revalidation, start there first. Add TanStack Query when that server-first flow stops being enough for the interaction you need.
Next.js & React.js Revolution is a strong daily read if you want practical coverage of React, Next.js, architecture trade-offs, and production patterns that teams can apply immediately. Explore more at Next.js & React.js Revolution.






















Add Comment