Prefetch on the server and hydrate the query cache
When a page is server-rendered, prefetch its queries into a QueryClient created for that request, dehydrate the cache, and hydrate it on the client, so components read the same cached data with no second fetch.
Implementation
- Create a new
QueryClientfor each request on the server; never share one across requests. - Prefetch with the same query options factories that client components use, so the keys match.
- Pass
dehydrate(queryClient)toHydrationBoundary, or to your framework's equivalent, around the components that read the data. - Set a default
staleTimeabove zero on the clientQueryClient, so hydrated data is not refetched immediately. - When serializing dehydrated state into HTML yourself, use a serializer that escapes it for a script tag; plain
JSON.stringifyoutput can break out of the tag. - Only successful queries are dehydrated by default; configure
shouldDehydrateQuerywhen you need others. - In a router with loaders, such as TanStack Start, call
ensureQueryDatain the loader and read withuseSuspenseQueryin the component.
Rationale
Data fetched on the server outside the query cache, such as through props, is invisible to TanStack Query, so the client fetches it again and components juggle two sources.
Dehydrating puts the server's results into the client cache under the same keys.
A server QueryClient shared across requests keeps one user's data in memory where another request can read it.
Examples
Incorrect (counterexample):
export async function getServerSideProps() {
return { props: { posts: await fetchPosts() } };
}
function PostsPage({ posts }: { posts: Array<Post> }) {
const { data } = useQuery({ queryKey: ['posts'], queryFn: fetchPosts });
return <PostList posts={data ?? posts} />;
}
The client fetches the posts again, and the component has to choose between two sources.
Correct (Next.js App Router):
export default async function PostsPage() {
const queryClient = new QueryClient();
await queryClient.prefetchQuery(postQueries.list());
return (
<HydrationBoundary state={dehydrate(queryClient)}>
<PostList />
</HydrationBoundary>
);
}
'use client';
export function PostList() {
const { data: posts } = useSuspenseQuery(postQueries.list());
return <ul>{posts.map((post) => <li key={post.id}>{post.title}</li>)}</ul>;
}
Validation
Load a server-rendered page and check in the network panel that the client does not refetch hydrated queries immediately.
Check that each request creates its own QueryClient.
A page that fetches only on the client, with no server rendering of that data, does not need dehydration.