Designing Next.js Applications With Clear Data and State Boundaries
How I structure Next.js applications with Server Components, Client Components, TanStack Query and clear ownership of form, route and server data.
Updated October 22, 2026
In a Next.js application, the frontend spans two environments. Server Components can load data and render the initial route, while Client Components handle the interactions that need browser state. Architecture becomes easier to follow when those responsibilities are explicit.
Problems appear when API data is copied into unrelated local state, a filter is duplicated across components, or a form draft becomes confused with the saved database record. React provides the component model; Next.js provides the application framework; TanStack Query can manage server data that needs a browser cache and refetch lifecycle.
The pattern that has helped me most is simple: name the state, name its owner, and give it one update path.
Start with four kinds of state
| State | Examples | Good default home |
|---|---|---|
| UI state | Modal visibility, an expanded row, a temporary filter | The nearest component that needs it |
| Route state | Search query, page number, selected workspace | URL search params or route params |
| Server state | Orders, profile data, permissions, search results | Server render or a query cache |
| Form state | Draft input, validation errors, submit status | The form boundary |
These categories describe ownership. A small application can handle them with React and its framework before adding more tools. Route state is often also UI state; keep it in the URL when sharing, refreshing, or browser navigation should preserve it.
Lift state only when components must coordinate
If two sibling components need to stay in sync, lift their shared state to their closest common parent. If only one component owns the interaction, keep it local. This keeps the dependency graph understandable and prevents a small interaction from triggering a broad application update.
Treat server data differently from client state
Server data has a lifecycle that local state does not: it can be stale, loading, rejected, retried, invalidated, or changed by another user. A query library can help, but the important thing is the contract.
For every query, decide:
- What identifies the result? Include those inputs in the cache key.
- How fresh does it need to be?
- Which mutation invalidates or updates it?
- What does the user see while it is loading, empty, or failed?
In a framework with Server Components, render suitable initial data on the server and keep event handlers, browser APIs, and stateful interactions in Client Components. In a client rendered application, make loading and data fetching boundaries equally explicit.
Use TanStack Query for interactive server data
A dashboard that refetches orders after a mutation benefits from a browser query cache. A static information page may only need server rendering. Choose the data path according to how the page behaves after it loads.
For the following TanStack Query v5 example, place a provider around the relevant Client Components:
// app/providers.tsx
'use client'
import { useState, type ReactNode } from 'react'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
export function Providers({ children }: { children: ReactNode }) {
const [client] = useState(() => new QueryClient({
defaultOptions: { queries: { staleTime: 60_000 } },
}))
return <QueryClientProvider client={client}>{children}</QueryClientProvider>
}
Render Providers around the interactive subtree from the application layout. This creates a cache for that mounted provider; it does not replace the database or Next.js server caching.
// components/OrderList.tsx
'use client'
import { useQuery } from '@tanstack/react-query'
type Order = { id: string; status: string }
export function OrderList({ workspaceId }: { workspaceId: string }) {
const orders = useQuery({
queryKey: ['orders', workspaceId],
queryFn: async ({ signal }): Promise<Order[]> => {
const response = await fetch(
`/api/workspaces/${encodeURIComponent(workspaceId)}/orders`,
{ signal }
)
if (!response.ok) throw new Error('Could not load orders')
return response.json()
},
})
if (orders.isPending) return <p>Loading orders…</p>
if (orders.isError) return <p>Orders could not be loaded. Please retry.</p>
return <ul>{orders.data.map(order => (
<li key={order.id}>{order.id}: {order.status}</li>
))}</ul>
}
This example assumes an existing Route Handler that authenticates the request, checks workspace access and returns the documented order shape. The workspace ID in a query key separates cached results; the server still has to enforce permissions.
After a successful order mutation, update the corresponding cached result or invalidate ['orders', workspaceId]. Include filters and pagination in the key when they change the result. For server prefetching and hydration, use the TanStack Query guide for Next.js and Server Components.
Design forms as a boundary
Forms are where UI state meets untrusted input. A good form has one schema, clear pending and error states, and a server side validation path that does not trust the browser.
For larger dynamic forms, React Hook Form can reduce re renders and a schema validator can keep client and server rules aligned. But the tool is secondary. The important rule is that a successful looking client form must never be the only validation boundary.
Make loading and failure part of the design
A human friendly frontend does not leave people staring at a spinner. It explains what is happening and gives them a recovery path.
Loading
Preserve already loaded content where possible and make the pending action obvious. A whole page loader for a small mutation usually feels broken.
Failure
Show an actionable message, retain user entered form data, and make retry safe. Do not discard a user’s work because one request timed out.
A practical review checklist
- Can every state value name one owner and one source of truth?
- Does the URL carry state that should survive refresh, sharing, or navigation?
- Does every mutation update or invalidate the data it changed?
- Are loading, empty, permission denied, and error states designed intentionally?
- Is the server still validating every important input and permission?
That architecture is not glamorous, but it is what makes a frontend easier to extend six months later. Good React code feels less like magic when the state has somewhere obvious to live.
For the underlying state rules, see React's state management guide.