RA
RenovateAPIEngineering Hub
Frontend & Fullstack Development

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.

AAbhishek6 min read
Frontend & Fullstack Development6 min read

Next.js Server Components + TanStack Query: Fixing Double-Fetches and Cache Drift

RENOVATEAPI ARCHITECTURAL SPEC
CANONICAL GUIDE

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.

RenovateAPI Engineering Suite

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.

Weekly Engineering Dispatch

Subscribe to RenovateAPI

Get weekly architectural guides, API refactoring strategies, and technical SEO updates delivered directly to your inbox.

Discussion (2)

A
Alex Rivera
2 hours ago

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.

S
Sophia Chen
1 day ago

The schema JSON-LD and FAQ block structure really helps with indexing. Great technical detail on entity mentions too.

Suggested Related Articles