TanStack Query in the Next.js App Router: Prefetch and Hydrate

Photo by Nicola since 1972 on flickr
A plain fetch call in a Server Component only covers the first render, it has no built-in mechanism for background refetching, cache invalidation after a mutation, or deduplicating requests across sibling components. TanStack Query keeps managing the data on the client after the page loads, while the App Router still lets you prefetch on the server so the first paint has no loading spinner. The two are complementary rather than competing tools.
HydrationBoundary is a component that receives the dehydrated, serialized QueryClient state from a Server Component and loads it into the browser's QueryClient cache before any child component renders. Without it, the data prefetched on the server never reaches the client cache, so a useQuery call in a Client Component underneath would trigger a brand-new fetch instead of reading the already-fetched data.
Yes, and it is the recommended pattern for slow queries. Call prefetchQuery without an await, configure dehydrate to also serialize pending queries, and the in-flight promise streams to the client as part of the response. A Client Component using useSuspenseQuery on the same key then resumes that already-started request inside a Suspense boundary instead of firing a second one.
Set a non-zero staleTime on the QueryClient used for the server prefetch. The default staleTime is zero, which marks hydrated data as stale the instant it lands in the browser, causing useQuery to refetch in the background right after mount. Matching staleTime to how often the underlying data actually changes avoids this immediate, unnecessary refetch.
The most common causes are a queryKey on the client that does not exactly match the key used in the server prefetch, a default staleTime of zero, creating a new QueryClient instance inside the Client Component tree instead of reusing the hydrated one, or forgetting to wrap the tree in a HydrationBoundary altogether. Checking the Network tab for a duplicate request within a second of page load is the fastest way to spot which of these is happening.

Photo by Nicola since 1972 on flickr
The Next.js App Router already lets a Server Component await a fetch call directly, so a fair question is why bother adding TanStack Query at all. The honest answer is that fetch alone gives you data on first load and nothing else — no background refetching, no cache invalidation after a mutation, no request deduplication across sibling components, and no built in retry or stale-data handling on the client. Once a page has any interactive piece, a filter, a form that saves and reloads a list, a status badge that should update without a full page refresh, plain fetch stops being enough and you end up hand-rolling the exact cache logic that TanStack Query already solved years ago.
TanStack Query still owns that entire client-side lifecycle, and the App Router gives you a clean way to seed its cache from the server so the first paint is never a loading spinner. This post walks through the exact prefetch, dehydrate, and hydrate flow for the App Router, when it earns its keep over a plain fetch, and the mistakes that quietly cause a page to fetch every query twice. Everything here assumes TanStack Query v5, which is the version that shipped first-class support for streaming pending queries through a Suspense boundary rather than only supporting a fully-awaited prefetch.
Server rendering with TanStack Query is a three step handoff between the server and the browser. Each step has one job, and skipping any of them breaks the chain. It helps to think of it less as a special App Router feature and more as passing a pre-warmed cache as a prop, the same way you would pass any other server-computed value down to a Client Component.
A QueryClient must never be a module-level singleton on the server, because every request would then share the same cache across different users. Instead, wrap the client factory in React's cache function so it is scoped to a single request on the server, and lazily created once in the browser. This also sets a non-zero staleTime, which matters more than it looks: without it, a hydrated query is considered stale the instant it lands in the browser, and useQuery immediately refires the request in the background, defeating the whole point of prefetching. The same factory function should be imported by every route that needs prefetching, rather than each route defining its own QueryClient options, so staleTime and retry behavior stay consistent across the app.
// lib/get-query-client.ts
import { QueryClient, defaultShouldDehydrateQuery } from "@tanstack/react-query"
import { cache } from "react"
// React's cache() scopes this to a single request on the server,
// and creates a fresh browser QueryClient the first time it runs on the client.
export const getQueryClient = cache(() => new QueryClient({
defaultOptions: {
queries: {
// Prefetched data stays "fresh" for 60s so the client
// does not immediately refetch on mount.
staleTime: 60 * 1000,
},
dehydrate: {
// Also dehydrate queries that are still pending, so we
// can stream them and resolve on the client (see section 4).
shouldDehydrateQuery: (query) =>
defaultShouldDehydrateQuery(query) || query.state.status === "pending",
},
},
}))Set staleTime to a value that matches how often the underlying data actually changes. A product catalog might tolerate five minutes; an order status page might want ten seconds. There is no universally correct number.
The Server Component does the prefetch and wraps its children in a HydrationBoundary, passing in the dehydrated cache state. The Client Component underneath calls useQuery with the exact same queryKey and queryFn. If the key matches what was prefetched, useQuery reads directly from the hydrated cache and renders immediately with no network request and no loading state on first paint. Note that the queryFn itself still needs to exist in the Client Component even though it will not run on first load, because TanStack Query needs it available for any later refetch, whether that is triggered by a window refocus, a manual invalidate, or the staleTime window expiring.
// app/[locale]/(pages)/orders/page.tsx — Server Component
import { HydrationBoundary, dehydrate } from "@tanstack/react-query"
import { getQueryClient } from "@/lib/get-query-client"
import { OrderList } from "./order-list"
import { getOrders } from "@/lib/orders"
export default async function OrdersPage() {
const queryClient = getQueryClient()
await queryClient.prefetchQuery({
queryKey: ["orders"],
queryFn: getOrders,
})
return (
<HydrationBoundary state={dehydrate(queryClient)}>
<OrderList />
</HydrationBoundary>
)
}
// app/[locale]/(pages)/orders/order-list.tsx — Client Component
"use client"
import { useQuery } from "@tanstack/react-query"
import { getOrders } from "@/lib/orders"
export function OrderList() {
// Same queryKey and queryFn as the server prefetch — this
// reads the hydrated cache instead of firing a new request.
const { data: orders } = useQuery({
queryKey: ["orders"],
queryFn: getOrders,
})
return (
<ul>
{orders?.map((order) => <li key={order.id}>{order.reference}</li>)}
</ul>
)
}The queryKey array in the Client Component must match the prefetched key exactly, including argument order and value types. A key of ["orders", 1] on the server and ["orders", "1"] on the client are different cache entries, and the client will silently issue a brand-new fetch instead of reading the hydrated data.
Awaiting every prefetchQuery call blocks the whole route until the slowest query resolves, which throws away one of the best features of the App Router: streaming. Instead, await only the queries that gate the above-the-fold content, and fire the slow ones without awaiting them. TanStack Query's dehydrate config can be told to also serialize queries that are still pending, which streams the in-flight promise to the client. A Client Component using useSuspenseQuery on that same key then simply resumes the already-started request inside a Suspense boundary, rather than starting a fresh one. This is the same mechanism React's own use API relies on for streaming a promise prop, TanStack Query just wraps it with caching, retries, and the rest of its query lifecycle on top.
// Streaming variant — do NOT await the slow query on the server.
export default async function DashboardPage() {
const queryClient = getQueryClient()
// Fast query: await it, it is ready before the HTML streams.
await queryClient.prefetchQuery({
queryKey: ["summary"],
queryFn: getSummary,
})
// Slow query: fire it but do not await. The pending promise is
// dehydrated and streamed to the client, which resumes it.
queryClient.prefetchQuery({
queryKey: ["annual-report"],
queryFn: getAnnualReport,
})
return (
<HydrationBoundary state={dehydrate(queryClient)}>
<Summary />
<Suspense fallback={<ReportSkeleton />}>
<AnnualReport />
</Suspense>
</HydrationBoundary>
)
}
// Client Component — suspends on the still-resolving stream
"use client"
function AnnualReport() {
const { data } = useSuspenseQuery({
queryKey: ["annual-report"],
queryFn: getAnnualReport,
})
return <Report data={data} />
}Reaching for TanStack Query on every Server Component is overkill. Use plain fetch or a direct database call when the data is only ever read once per request and never touched by client-side interactivity. Reach for TanStack Query when a Client Component needs to refetch, poll, invalidate after a mutation, or share the same query across components that do not have a common parent to pass props through. A good signal is whether you would ever need to call router.refresh just to reflect new data, if the answer is yes, that refresh is really a manual, worse version of what invalidateQueries already does.
| Situation | Plain fetch / async Server Component | TanStack Query with prefetch |
|---|---|---|
| Static content rendered once, no client interaction | Simpler, fewer dependencies | Unnecessary overhead |
| Data that a mutation on the page should invalidate | Requires a manual router refresh or full reload | invalidateQueries updates the cache instantly |
| Same data needed in multiple unrelated Client Components | Prop drilling or duplicate fetch calls | One cache entry, shared automatically by queryKey |
A useful rule of thumb: if the page never needs to talk to the server again after the initial render, you probably do not need TanStack Query at all for that data.
The most common bug in this setup is a page that fetches the same data twice — once on the server during prefetch, and again on the client the moment the component mounts. It usually comes down to one of these four causes, and all four are easy to fix once you know to look for them.
Open the Network tab while testing a prefetched page. If you see the same request fire twice within a second of page load, it is almost always a queryKey mismatch or a default staleTime of zero — check those two first before anything else.