The Shift Toward Unified Data and Routing Logic
The frontend architecture of production applications has undergone a fundamental realignment. Where developers once treated data fetching, caching, and routing as separate concerns managed by competing libraries, TanStack Query and TanStack Router have become the canonical pattern for managing application state in Next.js environments. This consolidation is not a matter of marketing momentum; it reflects a genuine convergence between the problems Next.js solved on the server and the problems that emerge when managing client-side logic in Server Components and Server Actions.
The 2024-2026 period has solidified this pattern because the constraints of Server Components forced developers to think differently about where logic lives. Next.js Server Components run only on the server and cannot use browser APIs or React hooks directly. This pushed developers to reason explicitly about client boundaries and to separate concerns that were previously entangled in a single component tree. TanStack Query and TanStack Router emerged as the natural solution because they preserve React's declarative model while respecting the architectural constraints of the app router.
How TanStack Query Complements Server Components
TanStack Query (formerly React Query) addresses a problem that Server Components do not fully solve: managing client-side state that derives from server data. When a Server Component fetches data, that data is rendered on the server and sent to the browser as HTML. If the user performs an action on the client that requires a fresh copy of that data, or if the data must be refetched asynchronously without a full page navigation, TanStack Query handles the mechanics.
Server Components excel at initial data loading. The developer writes an async component, awaits the data, and renders it. The server delivers rendered HTML directly. TanStack Query takes over when the data must be kept in sync with server state after the initial load.
Consider a dashboard that displays user metrics and allows the user to refresh those metrics without navigating. A Server Component loads the initial metrics and renders them. When the user clicks a refresh button on the client, that click handler is part of a Client Component nested inside or adjacent to the Server Component. A TanStack Query hook inside that Client Component manages the refetch request, caches the response, and updates the UI when new data arrives.
The relationship between Server Components and TanStack Query is complementary, not redundant. Server Components handle the initial load. TanStack Query manages ongoing synchronization and client-side derivations of that data.
Integration with Server Actions
The integration deepens when tRPC and Server Actions work together. A Server Action is a function marked with the 'use server' directive that runs on the server and can be called from Client Components. When a Server Action fetches or mutates data, the developer can invalidate TanStack Query caches to trigger a refetch.
'use server'
export async function updateUserName(userId: string, newName: string) {
const result = await db.users.update({
where: { id: userId },
data: { name: newName }
})
revalidatePath('/dashboard')
return result
}
Inside a Client Component, a TanStack Query mutation calls this Server Action and handles cache invalidation:
'use client'
import { useMutation, useQueryClient } from '@tanstack/react-query'
import { updateUserName } from '@/app/actions'
export function UserNameEditor({ userId }: { userId: string }) {
const queryClient = useQueryClient()
const mutation = useMutation({
mutationFn: async (newName: string) => {
return updateUserName(userId, newName)
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['user', userId] })
}
})
return (
<form onSubmit={(e) => {
e.preventDefault()
const formData = new FormData(e.currentTarget)
const newName = formData.get('name') as string
mutation.mutate(newName)
}}>
<input name="name" type="text" />
<button type="submit" disabled={mutation.isPending}>
Save
</button>
</form>
)
}
When the mutation succeeds, invalidateQueries triggers a refetch of the data associated with that query key. TanStack Query handles the network request, loading states, and error handling. The Server Action carries out the database operation.
TanStack Router and Client-Side Routing State
TanStack Router complements TanStack Query by providing a declarative, type-safe router for Client Components. While Next.js App Router handles file-based routing and Server Components, TanStack Router manages the client-side routing state and search parameters with more granularity than Next.js's useSearchParams hook.
TanStack Router enables developers to parse, validate, and synchronize URL search parameters as strongly typed objects. This is especially valuable when building data-heavy interfaces that depend on filter state, pagination, or sorting criteria maintained in the URL.
import { createRouter, RootRoute, Route } from '@tanstack/react-router'
const rootRoute = new RootRoute()
const usersRoute = new Route({
getParentRoute: () => rootRoute,
path: '/users',
component: UsersPage,
validateSearch: (search): { page?: number; sort?: string } => ({
page: (search as any).page || 1,
sort: (search as any).sort || 'name'
})
})
const routeTree = rootRoute.addChildren([usersRoute])
export const router = createRouter({ routeTree })
When the user navigates to /users?page=2&sort=date, TanStack Router parses the search parameters, validates them against the schema, and provides them to the component as strongly typed values. The component can then use those values as keys for TanStack Query:
export function UsersPage() {
const search = Route.useSearch()
const { data, isLoading } = useQuery({
queryKey: ['users', search.page, search.sort],
queryFn: () => fetchUsers(search.page, search.sort)
})
return (
<div>
{isLoading ? <Spinner /> : <UsersList users={data} />}
</div>
)
}
When the user changes a filter or pagination value, TanStack Router updates the URL. This triggers a re-render and updates the query key, which causes TanStack Query to fetch new data. The URL becomes the source of truth for filtering and pagination state, and TanStack Query manages the data itself.
tRPC: End-to-End Type Safety
tRPC bridges the Next.js backend and frontend with full TypeScript support across the wire. Rather than defining REST endpoints and duplicating type definitions on the client, developers define procedures on the server and call them from the client with complete type inference.
A tRPC backend router groups procedures into namespaces:
import { initTRPC } from '@trpc/server'
import { z } from 'zod'
const t = initTRPC.create()
export const appRouter = t.router({
users: t.router({
get: t.procedure
.input(z.object({ id: z.string() }))
.query(async ({ input }) => {
return db.users.findUnique({ where: { id: input.id } })
}),
update: t.procedure
.input(z.object({
id: z.string(),
name: z.string()
}))
.mutation(async ({ input }) => {
return db.users.update({
where: { id: input.id },
data: { name: input.name }
})
})
})
})
export type AppRouter = typeof appRouter
On the client, the tRPC client is instantiated with a link that points to the server endpoint. In Next.js with the app router, this often uses a fetch-based link that calls the tRPC endpoint during Server-Side Rendering or from a Client Component:
import { createTRPCClient, httpBatchLink } from '@trpc/client'
import type { AppRouter } from '@/server/router'
export const trpc = createTRPCClient<AppRouter>({
links: [
httpBatchLink({
url: 'http://localhost:3000/api/trpc',
fetch: async (input, init) => {
const response = await fetch(input as string, init)
return response
}
})
]
})
When calling the procedure from the client, TypeScript knows the exact shape of the input and output:
const user = await trpc.users.get.query({ id: 'user-1' })
// TypeScript infers that user has the shape of the User model
tRPC procedures can be called directly from Server Actions, bypassing the HTTP layer entirely. This pattern, called "direct invocation," avoids serialization overhead for server-to-server calls:
'use server'
import { appRouter } from '@/server/router'
export async function getUser(userId: string) {
const caller = appRouter.createCaller({
// context passed to all procedures
})
return caller.users.get({ id: userId })
}
When combined with TanStack Query, tRPC procedures become the mutation functions and query functions. The type safety flows from the procedure definition through the client code without intermediate steps:
'use client'
import { useMutation, useQuery } from '@tanstack/react-query'
import { trpc } from '@/trpc/client'
export function UserProfile({ userId }: { userId: string }) {
const { data: user } = useQuery({
queryKey: ['users.get', userId],
queryFn: () => trpc.users.get.query({ id: userId })
})
const updateMutation = useMutation({
mutationFn: (data) => trpc.users.update.mutate(data),
onSuccess: () => {
// Invalidate and refetch
}
})
return (
<div>
{user?.name}
<button onClick={() => updateMutation.mutate({ id: userId, name: 'New Name' })}>
Update
</button>
</div>
)
}
State Synchronization Across Layers
The challenge that TanStack Query and tRPC solve together is keeping client-side state synchronized with server state across multiple interaction patterns. A user can navigate, paginate, filter, mutate data, and expect the UI to reflect server state accurately without making redundant requests or displaying stale information.
The synchronization strategy relies on query keys as the unit of cache invalidation. When a mutation succeeds, the developer invalidates the query key that corresponds to the affected data. TanStack Query then automatically refetches that data:
const mutation = useMutation({
mutationFn: (data) => trpc.posts.create.mutate(data),
onSuccess: () => {
queryClient.invalidateQueries({
queryKey: ['posts.list']
})
}
})
This pattern scales because the cache keys encode the parameters that define the data. A query for ['posts', { userId: '1', page: 1 }] is distinct from ['posts', { userId: '2', page: 1 }]. When the user creates a post in one view and the query is invalidated, only the affected cache entries are refetched.
In scenarios where the server-side state changes due to another user's action, TanStack Query supports polling and real-time subscriptions. The refetchInterval option refetches data at a specified interval. For more sophisticated patterns, developers integrate libraries like Socket.io to push state changes to the client, triggering manual cache invalidation:
useEffect(() => {
const socket = io()
socket.on('post:created', (post) => {
queryClient.invalidateQueries({ queryKey: ['posts.list'] })
})
return () => socket.disconnect()
}, [queryClient])
Handling Error States and Optimistic Updates
A mature implementation of TanStack Query with tRPC addresses error handling and optimistic updates. Optimistic updates allow the UI to reflect changes immediately while the server request is in flight. If the request fails, the UI reverts to the previous state.
const mutation = useMutation({
mutationFn: (newData) => trpc.items.update.mutate(newData),
onMutate: async (newData) => {
await queryClient.cancelQueries({ queryKey: ['items'] })
const previousData = queryClient.getQueryData(['items'])
queryClient.setQueryData(['items'], (old) => [
...old,
{ ...newData, id: Math.random() }
])
return { previousData }
},
onError: (err, newData, context) => {
queryClient.setQueryData(['items'], context?.previousData)
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['items'] })
}
})
The onMutate hook runs before the request is sent. It saves the current cache state and updates the cache optimistically. If the mutation succeeds, onSuccess refetches to ensure consistency. If it fails, onError restores the previous state. This pattern provides responsive UI without sacrificing data accuracy.
Error handling within tRPC procedures relies on throwing structured errors that the client can react to. tRPC provides a TRPCError class that accepts a code and message:
export const appRouter = t.router({
items: t.router({
update: t.procedure
.input(z.object({ id: z.string(), data: z.object({}) }))
.mutation(async ({ input }) => {
const item = await db.items.findUnique({ where: { id: input.id } })
if (!item) {
throw new TRPCError({
code: 'NOT_FOUND',
message: 'Item not found'
})
}
return db.items.update({
where: { id: input.id },
data: input.data
})
})
})
})
On the client, the mutation's error callback receives the error object with the code and message:
const mutation = useMutation({
mutationFn: (data) => trpc.items.update.mutate(data),
onError: (error) => {
if (error.data?.code === 'NOT_FOUND') {
showNotification('Item not found', 'error')
} else {
showNotification('An error occurred', 'error')
}
}
})
Practical Patterns for Large Applications
In large applications, the modular structure of TanStack and tRPC becomes essential. Query keys should be organized hierarchically to avoid collisions and to make cache invalidation patterns clear. A common approach uses key factories:
export const postsKeys = {
all: ['posts'],
lists: () => [...postsKeys.all, 'list'],
list: (filters: PostFilters) => [...postsKeys.lists(), filters],
details: () => [...postsKeys.all, 'detail'],
detail: (id: string) => [...postsKeys.details(), id]
}
const { data } = useQuery({
queryKey: postsKeys.list({ userId: '1', page: 1 }),
queryFn: () => trpc.posts.list.query({ userId: '1', page: 1 })
})
mutation.onSuccess = () => {
queryClient.invalidateQueries({
queryKey: postsKeys.lists()
})
}
By invalidating postsKeys.lists(), all list queries are refetched regardless of their filter parameters. This avoids manually tracking which specific queries were affected.
Router integration follows a similar pattern. Define routes at the top level, compose them into a route tree, and use hooks like Route.useSearch() and Route.useNavigate() to read and modify routing state:
const usersRoute = new Route({
getParentRoute: () => rootRoute,
path: '/users',
validateSearch: (search): UserFilters => ({
page: (search as any).page || 1,
sort: (search as any).sort || 'name',
search: (search as any).search || ''
}),
component: UsersPage
})
export function UsersPage() {
const search = usersRoute.useSearch()
const navigate = useNavigate()
const handleFilterChange = (newFilters: UserFilters) => {
navigate({ search: newFilters })
}
}
This pattern keeps the URL in sync with the UI state automatically. When the user changes a filter, the URL updates, which triggers the query key to change, which causes TanStack Query to fetch new data.
Middleware and Request Customization
TanStack Query supports middleware to intercept and customize requests globally. Common use cases include adding authentication headers, logging, and retry logic. The httpBatchLink in tRPC accepts a fetch function that developers can customize:
export const trpc = createTRPCClient<AppRouter>({
links: [
httpBatchLink({
url: 'http://localhost:3000/api/trpc',
fetch: async (input, init) => {
const token = await getAuthToken()
const headers = new Headers(init?.headers)
headers.set('Authorization', `Bearer ${token}`)
const response = await fetch(input as string, {
...init,
headers
})
if (response.status === 401) {
redirectToLogin()
}
return response
}
})
]
})
TanStack Query's built-in retry mechanism handles transient failures automatically. By default, Query retries failed requests three times with exponential backoff. This behavior can be customized per query or globally:
const queryClient = new QueryClient({
defaultOptions: {
queries: {
retry: 3,
retryDelay: (attemptIndex) => Math.min(1000 * 2 ** attemptIndex, 30000)
}
}
})
Integration with Server-Rendered Data
One architectural challenge in Next.js is populating TanStack Query cache with data already fetched on the server. Rather than fetching the same data twice (once on the server and again on the client), developers can hydrate the cache using hydrate:
import { dehydrate, HydrationBoundary, QueryClient } from '@tanstack/react-query'
export default async function Page() {
const queryClient = new QueryClient()
await queryClient.prefetchQuery({
queryKey: ['posts', 1],
queryFn: () => trpc.posts.list.query({ page: 1 })
})
return (
<HydrationBoundary state={dehydrate(queryClient)}>
<PostList />
</HydrationBoundary>
)
}
The prefetchQuery method fetches the data on the server. The dehydrate function serializes the cache state and sends it to the client. Inside the client component, the HydrationBoundary wraps components that will use the cached data. When those components call useQuery with the same query key, they receive the pre-fetched data immediately without making another request.
This pattern is crucial for performance. It eliminates the waterfall effect where the server renders the HTML, the client hydrates, the client code mounts, and only then does the first data fetch occur. The data is already present when the page loads.
Monitoring and DevTools
TanStack Query and tRPC provide developer tools for debugging cache state and network requests. TanStack Query DevTools displays all active and inactive queries, their cache state, their age, and the ability to manually refetch or invalidate them. Installation is straightforward:
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { ReactQueryDevtools } from '@tanstack/react-query-devtools'
export function Providers({ children }) {
const queryClient = new QueryClient()
return (
<QueryClientProvider client={queryClient}>
{children}
<ReactQueryDevtools initialIsOpen={false} />
</QueryClientProvider>
)
}
The devtools panel appears as a floating button in the bottom corner of the browser during development. Clicking it opens a panel that shows all queries, their status, cache age, and data payload. This is invaluable when debugging state synchronization issues.
tRPC provides similar observability through the loggerLink, which logs all procedure calls to the browser console:
import { loggerLink } from '@trpc/client'
export const trpc = createTRPCClient<AppRouter>({
links: [
loggerLink({
enabled: () => process.env.NODE_ENV === 'development'
}),
httpBatchLink({
url: 'http://localhost:3000/api/trpc'
})
]
})
The Standard Has Crystallized
By 2026, the combination of TanStack Query, TanStack Router, and tRPC has become the standard approach for managing client-side data and routing in Next.js applications. This is not because of any single breakthrough feature but because these libraries solve real problems that every application faces: caching, synchronization, type safety, and routing state management. They compose cleanly with Next.js Server Components and Server Actions, filling the gap left by the shift away from traditional client-side frameworks.
Developers who adopt this stack early gain consistency across their codebase. Data fetching, caching, and mutation follow a uniform pattern. Routing state is declarative and type-safe. Network requests are fully typed from endpoint to client. The URL serves as a persistent source of truth for filtering and pagination. Errors are handled structurally. Optimistic updates provide responsive UX. These are not novel concepts, but the integration of these libraries into a cohesive system is the defining architectural pattern of 2026.
For professional Web3 documentation or a full-stack Next.js web application, consider reaching out via my Fiverr profile at https://fiverr.com/meric_cintosun.
Top comments (0)