Prefetching, SSR, and SSG with TanStack Query
In Mastering React Hydration we looked at hydration from the runtime's point of view: the server ships HTML, React walks the existing DOM, attaches event listeners, and the page becomes interactive. We spent most of that article worrying about mismatches — what happens when the markup React expects and the markup it finds disagree.
Hydration of markup is only half the story. The other half is hydration of data. Your server rendered a product page with real prices in it; the moment React takes over on the client, the component tree needs those same prices in memory, or your carefully server-rendered page will flash a spinner and refetch everything it just displayed.
That's the problem TanStack Query's SSR support solves. This article is about the data half: how to prefetch on the server, how to serialize a cache across the network boundary, and how the pattern changes between SSG, SSR, and React Server Components.
The problem, concretely
Here's a component that works fine in a client-only app:
function ProductPage({ id }: { id: string }) {
const { data, isPending } = useQuery({
queryKey: ['product', id],
queryFn: () => fetchProduct(id),
})
if (isPending) return <Skeleton />
return <Product product={data} />
}
Render this on the server and you get <Skeleton /> in your HTML. Not a loading state that resolves quickly — a skeleton baked into the document, permanently, because useQuery does not suspend or fetch during SSR. Then the browser hydrates, the query fires, and a few hundred milliseconds later the real content appears.
You've paid for server rendering and received none of its benefits. Crawlers see a skeleton. Your LCP is the client fetch, not the server response.
Two things need to happen:
- Prefetch — the data must be fetched on the server, before or during render.
- Dehydrate/hydrate — the resulting cache must be serialized into the HTML payload and restored into the client's
QueryClientbefore the first client render.
The core primitives
Three functions do almost all the work.
prefetchQuery fills a QueryClient on the server. It's fetchQuery without the return value and — importantly — without throwing. A failed prefetch just leaves the query absent, and the client refetches normally.
dehydrate walks a QueryClient and produces a plain JSON-serializable object of its queries and mutations.
HydrationBoundary takes that object and merges it into the client's QueryClient during render, before children run their useQuery calls.
// server
const queryClient = new QueryClient()
await queryClient.prefetchQuery({
queryKey: ['product', id],
queryFn: () => fetchProduct(id),
})
const state = dehydrate(queryClient)
// client
<HydrationBoundary state={state}>
<ProductPage id={id} />
</HydrationBoundary>
ProductPage doesn't change at all. That's the point of the design: useQuery finds fresh data already sitting in the cache and returns it synchronously on the first render. No skeleton, no refetch, no mismatch.
Rule zero: one QueryClient per request
Before any code, internalize this: never share a QueryClient between requests on the server.
A module-level new QueryClient() in a browser app is fine — one user, one cache. On the server it's a cross-request cache shared by every visitor simultaneously. One user's account data lands in another user's HTML. This is the single most damaging mistake in server-side TanStack Query, and it fails silently under low traffic.
The safe pattern:
import { QueryClient, isServer } from '@tanstack/react-query'
import { cache } from 'react'
function makeQueryClient() {
return new QueryClient({
defaultOptions: {
queries: {
staleTime: 60 * 1000,
},
},
})
}
// On the server, React's `cache` scopes this per request.
const getServerQueryClient = cache(makeQueryClient)
let browserQueryClient: QueryClient | undefined
export function getQueryClient() {
if (isServer) return getServerQueryClient()
// In the browser, reuse one client so React doesn't discard the
// cache on a suspended initial render.
browserQueryClient ??= makeQueryClient()
return browserQueryClient
}
Two different lifetimes, deliberately. Per-request on the server; per-tab in the browser.
Rule one: set a non-zero staleTime
TanStack Query defaults staleTime to 0 — every query is stale the instant it resolves. Combined with refetchOnMount, that means your beautifully hydrated data is immediately considered out of date and refetched on the client the moment it mounts.
You did all that prefetching work and still get a network request on load.
Set a staleTime that reflects how long the data is actually good for. Sixty seconds is a reasonable global default; tune per query as needed:
new QueryClient({
defaultOptions: {
queries: { staleTime: 60 * 1000 },
},
})
If you genuinely want the freshest possible data on arrival, that's a legitimate choice — but make it a choice, not an accident.
SSG: prefetch at build time
Static generation is the simplest case, because there is no request. You fetch at build time, dehydrate, and the state ships inside the static payload.
With the Next.js Pages Router:
export async function getStaticProps() {
const queryClient = new QueryClient()
await queryClient.prefetchQuery({
queryKey: ['posts'],
queryFn: fetchPosts,
})
return {
props: { dehydratedState: dehydrate(queryClient) },
revalidate: 3600,
}
}
And in _app.tsx:
export default function App({ Component, pageProps }: AppProps) {
const [queryClient] = useState(() => new QueryClient({
defaultOptions: { queries: { staleTime: 60 * 1000 } },
}))
return (
<QueryClientProvider client={queryClient}>
<HydrationBoundary state={pageProps.dehydratedState}>
<Component {...pageProps} />
</HydrationBoundary>
</QueryClientProvider>
)
}
Note useState(() => new QueryClient()) rather than a module-level instance — in the Pages Router this is what keeps the client per-tab and prevents the server-side sharing problem.
The interesting property of SSG plus TanStack Query is that the build-time snapshot is a starting point, not a commitment. The static HTML has the data from build time; the client cache has the same data marked with a build-time timestamp. As soon as it goes stale, normal refetch rules apply. You get static delivery with live-updating behavior — stale-while-revalidate implemented in the cache layer rather than the CDN.
SSR: prefetch per request
getServerSideProps is structurally identical, but now the QueryClient must be created inside the handler, once per request:
export async function getServerSideProps(ctx: GetServerSidePropsContext) {
const queryClient = new QueryClient()
await queryClient.prefetchQuery({
queryKey: ['product', ctx.params!.id],
queryFn: () => fetchProduct(ctx.params!.id as string),
})
return { props: { dehydratedState: dehydrate(queryClient) } }
}
Prefetch independent queries in parallel — awaiting them in sequence turns a 60ms and an 80ms request into 140ms of TTFB:
await Promise.all([
queryClient.prefetchQuery({ queryKey: ['product', id], queryFn: () => fetchProduct(id) }),
queryClient.prefetchQuery({ queryKey: ['reviews', id], queryFn: () => fetchReviews(id) }),
])
Don't prefetch everything on the page. Prefetch what's above the fold and what matters for SEO; let the rest load on the client. Every awaited prefetch is TTFB you're charging to every visitor.
App Router: prefetch in Server Components
With React Server Components the shape changes. There's no getServerSideProps — you prefetch directly in the async Server Component that owns the route, and wrap the client subtree in a HydrationBoundary:
// app/products/[id]/page.tsx (Server Component)
import { dehydrate, HydrationBoundary } from '@tanstack/react-query'
import { getQueryClient } from '@/lib/query-client'
import Product from './product'
export default async function Page({ params }: { params: Promise<{ id: string }> }) {
const { id } = await params
const queryClient = getQueryClient()
await queryClient.prefetchQuery({
queryKey: ['product', id],
queryFn: () => fetchProduct(id),
})
return (
<HydrationBoundary state={dehydrate(queryClient)}>
<Product id={id} />
</HydrationBoundary>
)
}
// app/products/[id]/product.tsx
'use client'
export default function Product({ id }: { id: string }) {
const { data } = useQuery({
queryKey: ['product', id],
queryFn: () => fetchProduct(id),
})
return <ProductView product={data!} />
}
The queryFn must be importable from both environments here, since it runs on the server during prefetch and on the client during refetch.
You can nest HydrationBoundary freely — each one merges into the same client cache. That means a layout can prefetch navigation data while a page prefetches its own, and they compose without coordination.
Streaming instead of awaiting
Awaiting a prefetch blocks the response. If a query is slow and not critical, you can stream it instead: don't await, wrap the consumer in <Suspense>, and let the data arrive after the shell.
The @tanstack/react-query-next-experimental package handles the plumbing:
'use client'
import { ReactQueryStreamedHydration } from '@tanstack/react-query-next-experimental'
export function Providers({ children }: { children: React.ReactNode }) {
const queryClient = getQueryClient()
return (
<QueryClientProvider client={queryClient}>
<ReactQueryStreamedHydration>{children}</ReactQueryStreamedHydration>
</QueryClientProvider>
)
}
With this in place, useSuspenseQuery in a client component fetches on the server during SSR, and its result is streamed into the client cache as it resolves — no explicit prefetch, no HydrationBoundary.
If you're doing manual prefetching and want pending queries streamed rather than awaited, configure dehydration to include them:
new QueryClient({
defaultOptions: {
dehydrate: {
shouldDehydrateQuery: (query) =>
defaultShouldDehydrateQuery(query) || query.state.status === 'pending',
},
},
})
This is the direct analogue of streaming HTML from the hydration article: the shell arrives immediately, holes fill in progressively, and hydration happens in pieces rather than all at once.
Client-side prefetching: the other half
Server prefetching optimizes the first page. Client prefetching optimizes every page after it — and it's where the biggest perceived wins usually are, because you can start the fetch before the user has committed to navigating.
function ProductLink({ id }: { id: string }) {
const queryClient = useQueryClient()
const prefetch = () => {
queryClient.prefetchQuery({
queryKey: ['product', id],
queryFn: () => fetchProduct(id),
staleTime: 60 * 1000,
})
}
return <Link href={`/products/${id}`} onMouseEnter={prefetch} onFocus={prefetch}>…</Link>
}
The staleTime here matters: without it, a hover-prefetch followed by a click 200ms later will refetch immediately, since the data was born stale. With it, the navigation renders from cache instantly.
Hover gives you roughly 100–300ms of head start. onFocus covers keyboard users. For lists, an IntersectionObserver that prefetches as rows scroll into view works well; just cap concurrency so you don't fire fifty requests at once.
There's also usePrefetchQuery, which fires a prefetch during render rather than in an event handler — useful for warming a query a child component will suspend on, without waterfalling.
Infinite queries
Same idea, slightly different API. Prefetch just the first page; the rest load on demand:
await queryClient.prefetchInfiniteQuery({
queryKey: ['feed'],
queryFn: ({ pageParam }) => fetchFeed(pageParam),
initialPageParam: 0,
pages: 1,
})
Omitting pages prefetches all pages, which for an infinite list means fetching until getNextPageParam returns undefined — on an unbounded feed, that's a hang. Always pass it.
Serialization: what can cross the wire
dehydrate produces something that must survive JSON.stringify. Date objects become strings. Map, Set, undefined, BigInt, and class instances don't round-trip.
If your data has richer types, install a custom serializer — superjson is the usual choice — and configure it in your framework's SSR integration. Otherwise, normalize at the queryFn boundary: return plain JSON, parse dates in a select or at the point of use. That keeps the serialization contract explicit rather than accidental.
Also remember that everything you dehydrate is embedded in the HTML, visible in view-source. Don't prefetch queries containing tokens, internal IDs, or fields the current user isn't authorized to see. Server-side data filtering is not optional just because the component only renders a subset.
Common failure modes
Query key mismatch. The server prefetched ['product', 42] and the client asks for ['product', '42']. Different keys, cache miss, refetch — and it looks exactly like hydration working correctly until you check the network tab. Normalize key types, ideally with a shared query-key factory used by both sides.
Refetch on mount despite hydration. Almost always staleTime: 0. See above.
gcTime too short for streaming. With streamed dehydration, a query is created on the server and consumed on the client some time later. If the server's gcTime elapses in between, the query is garbage collected before it's sent. Keep server gcTime comfortably above your streaming window.
Prefetching in a Client Component. prefetchQuery inside a 'use client' component during SSR doesn't do what you want — the prefetch must happen in a Server Component or a data-fetching function.
Over-prefetching. Ten awaited prefetches on a route means the slowest one determines TTFB for everyone. Await what's critical; stream or client-fetch the rest.
How to think about it
The mental model that makes all of this click: the dehydrated cache is a second payload alongside the HTML, and it hydrates on the same schedule.
The hydration article was about React reconciling its virtual tree against server-rendered DOM. This is the same operation one layer up — the client's QueryClient reconciling against a server-produced snapshot of itself. When both succeed, the user sees real content in the first paint and it stays on screen without a flicker. When either fails, you get the flash: correct HTML replaced by a spinner replaced by the same content again.
Get the query keys identical, the QueryClient request-scoped, and the staleTime non-zero, and the two hydrations line up. Everything else — streaming, infinite queries, hover prefetch — is refinement on top of that foundation.