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 Server Components + TanStack Query: Fixing Double-Fetches and Cache Drift
If you've wired up the Next.js App Router with TanStack Query, you've probably hit one of three problems without immediately knowing why: your product page fetches the same data twice, one user's dashboard occasionally flashes another user's numbers, or a mutation succeeds but the UI keeps showing the old value until a hard refresh. All three come from the same root cause — treating the server's data layer and the client's data layer as if they're the same cache when they're not.
Server Components fetch data once, on the server, and ship HTML. TanStack Query fetches data on the client and keeps it warm for interactions the server can't handle — refetch on focus, optimistic updates, polling. The moment you use both in the same tree, you need a deliberate handoff between them. Skip it and you get double-fetching at best, cross-user data bleed at worst.
The three failure modes
Double-fetching is the obvious one: a Server Component fetches products for SSR, then the client component mounts and useQuery fires the exact same request again, because as far as the client cache is concerned, nothing has been fetched yet.
Server-side cache bleeding is the dangerous one. If you create a single QueryClient at module scope and import it everywhere, every incoming HTTP request on the server shares that one instance. Under load, that means request A's prefetched data can still be sitting in the cache when request B reads from it — and if request A's data was scoped to a logged-in user, you've just leaked it.
Cache drift after Server Actions shows up more subtly. You run a mutation through a Server Action and call revalidatePath, which correctly invalidates the Next.js router cache. But TanStack Query on the client has no idea that happened — its own cache is untouched, so the UI keeps rendering stale data until something else triggers a refetch.
None of these are TanStack Query bugs. They're what happens when two independent caching systems sit next to each other with no coordination layer.
Pattern 1: a request-scoped QueryClient
The fix for cache bleeding is to stop treating the server like the browser. On the server, every request gets its own fresh QueryClient. On the client, you want the opposite — a single persistent instance that survives re-renders, or you'd lose your cache on every navigation.
// 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 check does the heavy lifting here. It's a small function, but skipping it is the single most common way this integration goes wrong in production.
Pattern 2: prefetch on the server, hydrate on the client
Once you're not sharing state across requests, you can prefetch inside an async Server Component and hand the result to the client tree via dehydrate and HydrationBoundary. The client's useQuery call then reads from that hydrated cache on mount instead of firing a new request.
// 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();
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>
<HydrationBoundary state={dehydrate(queryClient)}>
<ProductListClient />
</HydrationBoundary>
</div>
);
}
// app/products/ProductListClient.tsx
'use client';
import { useQuery } from '@tanstack/react-query';
import { fetchProducts } from '@/lib/api';
export function ProductListClient() {
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>
);
}
Two things have to match exactly for this to work: the queryKey on the server's prefetchQuery and the client's useQuery. If they diverge — even by a different object key order in some setups — the client won't find the hydrated data and quietly falls back to fetching again. Worth double-checking when this "isn't working" and you can't see why.
Pattern 3: invalidate the client cache after Server Actions
revalidatePath solves half the problem. It tells Next.js to regenerate the RSC payload on the next navigation, but it says nothing to the TanStack Query cache already sitting in the browser. If a mutation runs through a Server Action, invalidate the matching query key on the client once the mutation resolves:
// 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: () => {
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>
);
}
This is the step people skip because it feels redundant — "I already revalidated the path, why invalidate again?" — but the two caches genuinely don't talk to each other. revalidatePath and invalidateQueries are solving different problems that happen to share a mutation.
Which approach actually holds up
| Strategy | Initial TTFB | Double fetch? | Client interactivity | Cache isolation |
|---|---|---|---|---|
Pure server fetching (fetch only) |
Fast | None | Limited — needs full router refresh | Server only |
| Pure client fetching (empty SSR shell) | Slow, blank HTML | None | Full | Client only |
| Server prefetch + HydrationBoundary | Fast, full HTML | None | Full — polling, optimistic updates | Request-scoped, isolated |
If your app needs polling, optimistic UI, or infinite scroll anywhere, pure server fetching isn't an option no matter how tempting the simplicity is — you'll end up reaching for router.refresh() everywhere and calling it a day, which works until users notice the flicker. Pure client fetching gives up too much on first load for anything public-facing or SEO-relevant. The prefetch-and-hydrate pattern is more setup, but it's the only one of the three that doesn't force a tradeoff between fast initial render and rich client interactivity.
The setup cost is real — a request-scoped client factory, matching query keys, and remembering to invalidate after every Server Action. But it's a one-time cost per project, not a per-feature one. Once getQueryClient() exists and the pattern is established, adding a new prefetched query to a new page is a five-minute job.
Accelerate your Frontend & Fullstack Development Modernization Roadmap
Need custom architecture auditing, automated OpenAPI contract generation, or zero-downtime microservice migration guidance for your engineering team?
Frequently Asked Questions
Do I still need TanStack Query if I'm already using Server Components for data fetching?
Yes, if your UI needs anything past the initial render — optimistic mutations, polling, refetch-on-window-focus, or infinite scroll. Server Components handle the first paint well, but they don't give you a client-side cache to mutate against. TanStack Query fills that gap; it just needs to be handed the server's data instead of re-fetching it.
Why can't I just create one QueryClient and export it as a singleton on the server?
A module-level singleton on the server is shared across every concurrent request your Node process handles. One user's prefetched, possibly private data sits in the same cache another user's request reads from. Each server request needs its own QueryClient instance; only the browser gets a persistent singleton.
Subscribe to RenovateAPI
Get weekly architectural guides, API refactoring strategies, and technical SEO updates delivered directly to your inbox.
Discussion (2)
Extremely helpful breakdown of the Strangler Fig pattern! We're currently refactoring a legacy Java monolith at work and the OpenAPI gateway routing tips saved us weeks of experimentation.
The schema JSON-LD and FAQ block structure really helps with indexing. Great technical detail on entity mentions too.
Suggested Related Articles
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.
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.