Skip to content
Back to Blogs
Next.jsReactTanStack QueryFrontend ArchitectureTypeScript

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.

D
Dheeraj Jha
October 22, 2026
6 min read

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

StateExamplesGood default home
UI stateModal visibility, an expanded row, a temporary filterThe nearest component that needs it
Route stateSearch query, page number, selected workspaceURL search params or route params
Server stateOrders, profile data, permissions, search resultsServer render or a query cache
Form stateDraft input, validation errors, submit statusThe 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.

The form submission path
Edit the formPreserve the user's draft and provide immediate validation feedback.
Submit and validate on the serverCheck the payload, identity, and permission to perform the operation.
Save and reconcileApply the mutation and refresh or update the relevant query cache.

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

Practical checklist
  1. Can every state value name one owner and one source of truth?
  2. Does the URL carry state that should survive refresh, sharing, or navigation?
  3. Does every mutation update or invalidate the data it changed?
  4. Are loading, empty, permission denied, and error states designed intentionally?
  5. 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.