+1 (781) 556-6029

Where data fetching belongs: Server Components, TanStack Query, or both

The question arrives in almost every App Router review: now that components can await on the server, do we still need a client data-fetching library? The answer we give is that they solve different problems, that most applications end up with both, and that the trouble starts when nobody wrote down which reads go where. Codebases without that rule accumulate two copies of the same entity — one fetched on the server for the first paint, one fetched again on the client to keep it fresh — and the bugs that follow are stale-data bugs, which are expensive to reproduce.

This post is the decision rule we use, and the parts of it that changed with recent Next.js releases.

Two problems, not one

Server Components answer: how do I get data into the initial HTML without shipping a fetching library, a waterfall, and a loading spinner to the browser? The component runs on the server, awaits its data, and renders. The query code, the ORM, the credentials, and the response parsing stay out of the bundle.

A client cache — TanStack Query, SWR, Apollo — answers a different question: how do I keep data in the browser consistent while the user interacts with it? Deduplicating concurrent requests, refetching on window focus, polling, optimistic updates, retry with backoff, pagination state, and invalidating related entries after a mutation. None of that disappears when a page can render on the server, because none of it is about the first paint.

If you read the two lists and your application only needs the first one, you do not need a client cache. Content sites, dashboards that reload on navigation, marketing surfaces, admin screens with form-and-redirect flows: these are genuinely served by server rendering alone, and adding a cache library to them is weight with no payoff. Say so before installing anything.

The rule

Fetch on the server when the data is needed to render the page, is scoped to the request, and does not change while the user is looking at it. Fetch through a client cache when the data changes underneath the user, is fetched in response to interaction, or is shared across several components that must agree.

Applied to a typical SaaS screen:

  • The organization, the current user, the permissions, the page's primary record — server. They are known at request time and they gate the render.
  • A table that the user filters, sorts, and paginates without a navigation — client cache. The alternative is a URL round trip per keystroke, which you can do, but the interaction budget has to survive it.
  • A live status column, a notification count, anything polled — client cache.
  • Anything a mutation changes and the user expects to see immediately — client cache with an invalidation, or a server action with a revalidation. Pick one per entity, not both.

The last line is the one that matters in review. Two invalidation mechanisms operating on the same entity is how you get a list that updates in one place and not the other.

Hydrating a client cache from the server

The mixed case is well supported and underused: render on the server, hand the data to the client cache as initial state, let the cache own it from then on. There is exactly one first paint and no duplicate request.

// app/orders/page.tsx  (Server Component)
import { dehydrate, HydrationBoundary, QueryClient } from '@tanstack/react-query';
import { getOrders } from '@/lib/orders';
import { OrdersTable } from './orders-table';

export default async function OrdersPage() {
  const queryClient = new QueryClient();
  await queryClient.prefetchQuery({
    queryKey: ['orders', { status: 'open' }],
    queryFn: () => getOrders({ status: 'open' }),
  });

  return (
    <HydrationBoundary state={dehydrate(queryClient)}>
      <OrdersTable />
    </HydrationBoundary>
  );
}

OrdersTable is a Client Component calling useQuery with the same key. On first render the data is already there; afterwards the cache handles refetching, filters, and invalidation. Two details decide whether this works in practice. The query key on both sides must be identical, including the serialized filter object — a mismatch produces a silent second fetch on mount, visible as a duplicate request in the network panel and nowhere else. And the QueryClient must be created per request, not as a module singleton, or one user's data is dehydrated into another user's HTML. That is not a performance bug.

What changed in the framework, and why the defaults moved

Next.js 13 and 14 cached aggressively by default: fetch responses were cached unless you opted out, and client-side navigations served a router cache with a non-zero stale time. The result was a steady stream of "why is this data old" reports from teams who had not read the caching page, and a lot of cache: 'no-store' sprinkled as a superstition.

Next.js 15 reversed the defaults. fetch is no longer cached unless you ask, GET route handlers are not cached by default, and the client router cache has a stale time of zero for page segments. Correct by default, slower by default — which is the right trade for a default, and means caching is now something you opt into deliberately, per read.

The opt-in that followed is the use cache directive with explicit lifetimes and tags, which moves the caching decision to the function or component that owns the data instead of to a fetch option buried three layers down. If you are on 15 or later, prefer it over re-deriving the old implicit behavior. Whichever mechanism you use, write the cache lifetime and the invalidation trigger for each cached read somewhere a reviewer will see it. Caching bugs are not hard because the API is hard; they are hard because the intended lifetime was never stated.

If you are upgrading from 14, do not try to restore the old caching behavior in one commit. Take the reads that were relying on the implicit cache, decide per read whether they should be cached at all, and mark them explicitly. Most of them should not be. The ones that should are usually shared, slow, and not user-specific, and they are easy to name.

Waterfalls move, they do not vanish

The strongest argument for server fetching is removing the client-side waterfall: no bundle download, then a hook, then a fetch, then a render. But awaiting sequentially inside nested Server Components builds a server-side waterfall instead, and it is less visible because there is no network panel showing it.

Start parallel requests together and await them together. Where a slow read genuinely cannot be made fast, put it behind its own <Suspense> boundary so it streams in after the shell rather than holding the whole page. This is a real improvement to Largest Contentful Paint when the boundary is placed below the fold, and no improvement at all when the slow read is the largest contentful element — in that case you have moved a spinner, not a metric. Check with a trace before and after; the same discipline we apply to memoization applies here.

Mutations

Server Actions are a good fit for form-shaped writes: submit, mutate, revalidatePath or revalidateTag, re-render. useActionState gives you the pending and error states without a client cache.

They are a weaker fit for high-frequency, optimistic, or offline-tolerant writes — dragging a card between columns, toggling a row, editing inline. Those want the client cache's optimistic update and rollback, which is a solved problem there and hand-rolled everywhere else. A fair split: writes that navigate or reload a page go through Server Actions; writes that mutate a list the user is currently reading go through the cache that owns that list.

How to decide on an existing codebase

List the entities, not the components. For each entity, write two columns: where it is read from, and what invalidates it. The duplicates and the blanks are your findings, and there are usually three or four of each. Fix the duplicated entity first — it is the one generating stale-data reports — by picking a single owner and deleting the other read path.

The outcome to aim for is boring: someone new to the codebase can answer "where does this data come from, and when does it refresh?" from the code, without asking. If that is already true where you work, the framework's caching defaults are an implementation detail, and you can ignore most of the argument online about which fetching approach won.