TanStack Query Prefetching: SSR, Hydration, Dehydrate
TanStack Query's prefetching, dehydrate, and hydration APIs make SSR data loading straightforward — here's exactly how to wire it up without the guesswork.
Why Prefetching Matters for SSR in TanStack Query v5
Honestly, most React apps that reach for TanStack Query (formerly React Query) start client-side and never think about what happens at the server boundary. Then someone opens DevTools and sees the loading spinners on first paint, and suddenly SSR is on the roadmap.
TanStack Query v5 ships with a proper answer to this: prefetchQuery, dehydrate, and HydrationBoundary. These three primitives let you fetch data on the server, serialize the cache, and rehydrate it on the client — all without duplicating your fetch logic or writing manual getServerSideProps boilerplate for every query.
The mental model is simple. On the server you fill a QueryClient cache. Then you serialize that cache to a plain object with dehydrate(). You pass it to the client inside HydrationBoundary, and the client reads it before it ever fires a network request. Users get populated UI on first paint. That's the whole story.
If you're already doing smart things with React performance — memoization, suspense boundaries, lazy imports — prefetching is the logical next step. It attacks the most expensive part: the cold-start waterfall.
Setting Up a Shared QueryClient Factory
Don't share a single global QueryClient instance across server requests. This is the number-one mistake people make. Each request needs its own client, otherwise user A's data leaks into user B's response.
The fix is a factory function. Call it once per request, pass the resulting client through your server components or getServerSideProps, then throw it away when the request ends.
// lib/query-client.ts
import { QueryClient } from '@tanstack/react-query';
export function makeQueryClient() {
return new QueryClient({
defaultOptions: {
queries: {
// With SSR, we usually want to set some default staleTime
// above 0 to avoid refetching immediately on the client.
staleTime: 60 * 1000, // 1 minute
gcTime: 5 * 60 * 1000, // 5 minutes
},
},
});
}The staleTime of 60 * 1000 (one minute) is important here. Without it, data that arrives perfectly fresh from the server is immediately considered stale on the client, and TanStack Query fires a background refetch the moment the component mounts. That wastes bandwidth and causes subtle flicker. One minute gives you a comfortable window.
prefetchQuery in Next.js App Router Server Components
With the App Router you're working in React Server Components by default. The pattern is to create your QueryClient, call prefetchQuery, dehydrate it, and wrap your client tree in HydrationBoundary. Here's a real page-level example:
// app/posts/page.tsx
import {
HydrationBoundary,
QueryClient,
dehydrate,
} from '@tanstack/react-query';
import { makeQueryClient } from '@/lib/query-client';
import { PostList } from './PostList';
async function fetchPosts() {
const res = await fetch('https://api.example.com/posts', {
next: { revalidate: 60 }, // Next.js ISR — revalidate every 60s
});
if (!res.ok) throw new Error('Failed to fetch posts');
return res.json();
}
export default async function PostsPage() {
const queryClient = makeQueryClient();
await queryClient.prefetchQuery({
queryKey: ['posts'],
queryFn: fetchPosts,
});
return (
<HydrationBoundary state={dehydrate(queryClient)}>
<PostList />
</HydrationBoundary>
);
}The PostList client component can then call useQuery({ queryKey: ['posts'], queryFn: fetchPosts }) exactly as it would for a purely client-side flow. When it runs in the browser the cache is already populated. No loading state. No spinner. The query key ['posts'] acts as the bridge between server and client — they have to match exactly.
Notice you don't need to pass any data through props. HydrationBoundary handles the serialization boundary transparently. This is one of the things TanStack Query v5 got genuinely right compared to older patterns.
Dehydrate and HydrationBoundary Deep Dive
dehydrate(queryClient) returns a plain serializable object — DehydratedState — containing all the successful queries in the cache. Failed queries are excluded by default, which makes sense: you don't want to serialize an error state that the client should handle fresh.
You can customize what gets dehydrated. The shouldDehydrateQuery option takes a predicate. If you're caching user-specific data and public data in the same client, you might want to only dehydrate the public queries to avoid leaking session data into the HTML.
const dehydratedState = dehydrate(queryClient, {
shouldDehydrateQuery: (query) =>
// Only dehydrate queries that succeeded and are not user-specific
query.state.status === 'success' &&
!(query.queryKey as string[]).includes('user'),
});On the client, HydrationBoundary reads the state prop during render and calls hydrate(queryClient, state) internally. Any query in the dehydrated state gets inserted into the client's cache with the server timestamp. The client sees those entries as fresh (given your staleTime) and skips the fetch. It's worth noting that HydrationBoundary can be nested — you can have multiple boundaries at different points in the tree, each hydrating different slices of data.
Parallel Prefetching and Dependent Queries
Real pages don't fetch one thing. You've got posts, plus the current user, plus maybe some category metadata. Running these sequentially means waterfall latency. Run them in parallel with Promise.all.
export default async function DashboardPage() {
const queryClient = makeQueryClient();
await Promise.all([
queryClient.prefetchQuery({
queryKey: ['posts'],
queryFn: fetchPosts,
}),
queryClient.prefetchQuery({
queryKey: ['categories'],
queryFn: fetchCategories,
}),
queryClient.prefetchQuery({
queryKey: ['site-config'],
queryFn: fetchSiteConfig,
}),
]);
return (
<HydrationBoundary state={dehydrate(queryClient)}>
<Dashboard />
</HydrationBoundary>
);
}For dependent queries — where you need result A before you can fetch B — you have to await them sequentially. There's no magic here. Await the first prefetchQuery, extract the data from queryClient.getQueryData(['first-query']), then prefetchQuery the second one with that value in the query key or as a parameter.
What about pagination and infinite queries? TanStack Query v5 has prefetchInfiniteQuery for that. It accepts the same initialPageParam you'd pass to useInfiniteQuery. Prefetch only the first page server-side — prefetching ten pages of infinite scroll makes no sense and bloats your HTML.
Pages Router: getServerSideProps and getStaticProps Patterns
If you're still on the Pages Router (nothing wrong with that), the pattern is slightly different but the same primitives apply. You create the QueryClient in getServerSideProps, prefetch, dehydrate, and pass the dehydrated state as a prop.
// pages/posts/index.tsx
import {
GetServerSideProps,
InferGetServerSidePropsType,
} from 'next';
import {
dehydrate,
HydrationBoundary,
QueryClient,
} from '@tanstack/react-query';
import { PostList } from '@/components/PostList';
export const getServerSideProps: GetServerSideProps = async () => {
const queryClient = new QueryClient({
defaultOptions: { queries: { staleTime: 60_000 } },
});
await queryClient.prefetchQuery({
queryKey: ['posts'],
queryFn: () =>
fetch('https://api.example.com/posts').then((r) => r.json()),
});
return {
props: {
dehydratedState: dehydrate(queryClient),
},
};
};
export default function PostsPage({
dehydratedState,
}: InferGetServerSidePropsType<typeof getServerSideProps>) {
return (
<HydrationBoundary state={dehydratedState}>
<PostList />
</HydrationBoundary>
);
}For getStaticProps, the shape is identical — swap getServerSideProps for getStaticProps and add revalidate if you want ISR. The dehydrated state becomes part of the static HTML, so your data is baked in at build time and rehydrated on the client.
One thing to watch: JSON serialization. The dehydrated state goes through JSON.stringify when it's embedded in the page. Dates become strings. If your API returns Date objects (or you're parsing them), you'll need to handle re-parsing on the client. It's a common gotcha that causes subtle type mismatches — especially if you're being strict with TypeScript patterns.
Error Boundaries, Suspense, and Stale-While-Revalidate
TanStack Query plays well with React Suspense. If you set { suspense: true } (v4) or use useSuspenseQuery (v5), your components suspend while data loads. With prefetching, they never suspend on the server-populated data — the cache hit is synchronous.
Pair this with an ErrorBoundary wrapping your HydrationBoundary. If prefetchQuery throws on the server (network down, API returning 500), you'll want to decide whether to show a fallback or propagate the error. By default, a failing prefetchQuery doesn't throw — it just leaves that key empty in the cache, and the client will retry on mount. You can change this behavior by catching the promise yourself and deciding what to do.
The stale-while-revalidate behavior is where things get interesting at scale. Your server prefetches at t=0 with staleTime: 60_000. By the time the user navigates to the page, some seconds have passed. The client reads the cached data (still fresh), renders immediately, and schedules a background refetch only when staleTime expires. This is exactly the kind of performance win that reducing unnecessary re-renders can't get you on its own — it attacks the network, not the render.
Can you use this with optimistic updates? Yes. Prefetching just pre-populates the cache. Your client-side mutations and optimistic updates work exactly as they always did. The prefetched data is just the starting point.
Common Gotchas and Production Checklist
A few things that trip people up in production. First: never share a singleton QueryClient across requests in a Node.js server. This is cache poisoning. The makeQueryClient factory pattern above solves it, but it's easy to accidentally create a module-level queryClient and forget.
Second: make sure your query keys on the server and client match exactly. ['posts'] and ['posts', undefined] are different keys in TanStack Query. If your client-side hook passes optional filters as part of the key, your server prefetch needs to pass the same values — or the hydrated data won't be found and you'll get a double fetch.
Third: dehydrate by default only serializes queries with status === 'success'. If you're debugging and wondering why a query isn't being hydrated, check whether the prefetch actually succeeded. A console.log(queryClient.getQueryState(['your-key'])) in your server component will show you what's actually in the cache before dehydration.
You'll also want to think about bundle size. HydrationBoundary and dehydrate are included in the main @tanstack/react-query package — no extra install. But the dehydrated state itself is embedded in your HTML. 50 queries × 5KB average payload = 250KB of inline JSON, which tanks your Time to First Byte. Be selective about what you prefetch. The toast notification patterns and other UI state should never live in server-prefetched query cache — only actual remote data that users need on first paint.
FAQ
No, by default prefetchQuery swallows errors and leaves the query key absent from the cache. The client will attempt a fresh fetch on mount. If you want to surface the error server-side, wrap the call in try/catch and handle it explicitly.
dehydrate produces a structured, serializable snapshot of successful queries — it strips non-serializable internals and metadata you don't want in the HTML. You can't just stringify the whole QueryClient; it contains Promises, observers, and class instances that won't survive serialization.
Yes. TanStack Router has built-in loader support that integrates with TanStack Query via @tanstack/router-query. You call prefetchQuery inside the route loader function and the library handles dehydration and hydration for you. Same primitives, slightly different wiring.
Almost always a query key mismatch. The client-side useQuery key must exactly match the key passed to prefetchQuery. Also check that staleTime is greater than 0 — with staleTime at 0 (the default), data is considered stale immediately on mount and triggers a background refetch.
Not with prefetchQuery — mutations are write operations and don't have query keys in the same sense. If you know the result of a mutation ahead of time (e.g., creating a new record and wanting to show it immediately), you can use queryClient.setQueryData to manually seed the cache with the expected result before the mutation fires.
Essentially yes. In TanStack Query v4, it was called Hydrate. In v5 it was renamed to HydrationBoundary for clarity. The behavior is the same — it calls hydrate() internally and merges the dehydrated state into the nearest QueryClient provided by QueryClientProvider.