Stop Double-Fetching in Next.js App Router with TanStack Query
How to prefetch on the server, hydrate on the client, and keep your cache in sync after Server Actions — without the double-fetch penalty or leaking data across requests.

Abhishek
Full-Stack & AI Product Engineer
If you've wired up TanStack Query inside a Next.js App Router project, you've probably hit the same wall twice: your data loads once during SSR, then loads again the instant the client component mounts. Or worse, a Server Action mutates the database and your UI just sits there showing stale data until someone hits refresh.
Neither of these is a TanStack Query problem or a Next.js problem. They're a wiring problem, and they show up because Server Components and Query's client-side cache are two systems that don't know about each other unless you connect them on purpose.
The three failure modes worth knowing about
Double-fetching. The server fetches your data for SSR, sends down the HTML, and then the client component mounts and fires the exact same query again — because as far as the client's QueryClient is concerned, it has an empty cache. You pay for the request twice.
Cache bleeding on the server. This one's more serious than a performance annoyance. If you create a single QueryClient at the module level and reuse it across incoming requests, you're sharing one cache instance across concurrent users. User A's private data can end up served to User B's request. This is a data isolation bug, not just a UX one.
Cache drift after Server Actions. You run a mutation through a Server Action, revalidatePath does its thing on the Next.js side, but the client's TanStack Query cache never heard about it. The client keeps serving whatever it had cached before the mutation, and now your UI is lying to the user.
The fix for all three is the same underlying pattern: give the server its own disposable QueryClient per request, dehydrate it into the HTML, and rehydrate it on the client so the two caches start in sync — then keep them in sync after every mutation.
Request-scoped clients: one instance per request, not per app
The rule here is simple and non-negotiable: on the server, never reuse a QueryClient across requests. On the client, do the opposite — keep a single persistent instance so navigation doesn't blow away your cache.
// app/getQueryClient.ts
import { QueryClient, defaultShouldDehydrateQuery, isServer } from '@tanstack/react-query';
function makeQueryClient() {
return new QueryClient({
defaultOptions: {
queries: {
staleTime: 60 * 1000, // 1 minute default stale time
},
dehydrate: {
// Include pending queries if using streaming / Suspense
shouldDehydrateQuery: (query) =>
defaultShouldDehydrateQuery(query) || query.state.status === 'pending',
},
},
});
}
let browserQueryClient: QueryClient | undefined = undefined;
export function getQueryClient() {
if (isServer) {
// Server: always make a new query client per incoming request
return makeQueryClient();
} else {
// Browser: reuse or create the browser-side singleton client
if (!browserQueryClient) browserQueryClient = makeQueryClient();
return browserQueryClient;
}
}
The isServer branch is doing the actual work here. Everything else in this pattern depends on that check being right — get it backwards and you're back to sharing state across users.
Prefetch on the server, hydrate on the client
With the request-scoped client in place, prefetch inside an async Server Component and hand the dehydrated cache to a HydrationBoundary wrapping your client tree:
// app/products/page.tsx (Server Component)
import { dehydrate, HydrationBoundary } from '@tanstack/react-query';
import { getQueryClient } from '../getQueryClient';
import { ProductListClient } from './ProductListClient';
import { fetchProducts } from '@/lib/api';
export default async function ProductsPage() {
const queryClient = getQueryClient();
// Prefetch data directly inside the Server Component
await queryClient.prefetchQuery({
queryKey: ['products', { category: 'all' }],
queryFn: () => fetchProducts('all'),
});
return (
<div className="p-8 max-w-6xl mx-auto">
<h1 className="text-3xl font-bold mb-6">Product Catalog</h1>
{/* Pass dehydrated cache state to the client subtree */}
<HydrationBoundary state={dehydrate(queryClient)}>
<ProductListClient />
</HydrationBoundary>
</div>
);
}
// app/products/ProductListClient.tsx ('use client')
'use client';
import { useQuery } from '@tanstack/react-query';
import { fetchProducts } from '@/lib/api';
export function ProductListClient() {
// Reads instantly from the dehydrated server cache; no network fetch on initial mount!
const { data: products, isLoading } = useQuery({
queryKey: ['products', { category: 'all' }],
queryFn: () => fetchProducts('all'),
});
if (isLoading) return <div>Loading products...</div>;
return (
<div className="grid grid-cols-3 gap-6">
{products?.map((item) => (
<div key={item.id} className="p-4 border rounded-lg shadow-sm">
<h2 className="font-semibold">{item.title}</h2>
<p className="text-slate-600">${item.price}</p>
</div>
))}
</div>
);
}
Notice the query key in prefetchQuery matches the one in useQuery exactly — ['products', { category: 'all' }] on both sides. That match is what lets useQuery find the dehydrated data and skip the network call entirely on mount. Get the key slightly wrong on either side and you're silently back to double-fetching, with no error to tell you why.
Keeping the client cache honest after a Server Action
Prefetching solves the initial load. It does nothing for what happens after a mutation. Once you run a Server Action, you need to explicitly tell the client's QueryClient that its cached data is now wrong:
// components/AddProductButton.tsx
'use client';
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { createProductAction } from '@/app/actions/product-actions';
export function AddProductButton() {
const queryClient = useQueryClient();
const mutation = useMutation({
mutationFn: createProductAction,
onSuccess: () => {
// Invalidate client cache so TanStack Query immediately re-fetches updated list
queryClient.invalidateQueries({ queryKey: ['products'] });
},
});
return (
<button
onClick={() => mutation.mutate({ name: 'New Widget', price: 49 })}
disabled={mutation.isPending}
className="px-4 py-2 bg-blue-600 text-white rounded"
>
{mutation.isPending ? 'Saving...' : 'Add Product'}
</button>
);
}
revalidatePath inside the Server Action handles what happens on the next full navigation or reload. invalidateQueries handles what happens right now, in the tab the user is already looking at. You need both — they're solving for different moments, not competing for the same job.
Which approach actually fits your page
Not every route needs this full setup. If a page is genuinely static content with no client interactivity, plain server-side fetching is simpler and you should use it.
| Strategy | Initial TTFB | Double fetch? | Client interactivity (refetch/poll) | Cache isolation |
|---|---|---|---|---|
Pure server fetching (fetch only) |
Fast | None | Limited — needs a full router refresh | Server only |
| Pure client fetching (empty SSR) | Slow — blank HTML | None | Full | Client only |
Server prefetch + HydrationBoundary |
Fast — full HTML | None (reuses dehydrated state) | Full — polling, optimistic updates | Request-scoped, isolated |
If your page needs optimistic UI, polling, or window-refocus refetching, the prefetch-plus-hydration pattern is worth the extra file. If it doesn't, don't reach for it just because it's the "correct" architecture — a plain server fetch is less code and does the job.
Related Articles
View All Articles ↗Next.js Server Components + TanStack Query: Fixing Double-Fetches and Cache Drift
How to pair Next.js App Router Server Components with TanStack Query v5 without double-fetching, leaking data across requests, or serving stale caches after mutations.
Next.js Partial Prerendering in Production: The Suspense and Cache Traps Nobody Warns You About
A practical look at Next.js Partial Prerendering — how to structure Suspense boundaries correctly, why cookies() can silently kill your static shell, and how to stop CDNs from leaking one user's data to another.
Module Federation Between Vite and Next.js Without Breaking SSR
How to wire React Vite microfrontends into a Next.js host with Module Federation — avoiding duplicate React crashes, hydration mismatches, and shared dependency conflicts.