RA
RenovateAPIEngineering Hub
Frontend & Fullstack Development

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.

AAbhishek6 min read
Frontend & Fullstack Development6 min read

Stop Double-Fetching in Next.js App Router with TanStack Query

RENOVATEAPI ARCHITECTURAL SPEC
CANONICAL GUIDE

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.

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 using Server Components?

Yes, if your app needs optimistic mutations, polling, refetch-on-window-focus, or infinite scroll. Server Components handle the initial fetch well, but they can't do any of that client-side behavior on their own.

Why can't I just use one QueryClient instance for my whole app on the server?

Because the server handles many requests concurrently, and a single shared QueryClient will mix cached data between users. Each incoming request needs its own instance.

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