
Client-side data fetching
Every Next.js App Router project eventually hits the same fork in the road: some piece of UI needs data that the server rendered version can't (or shouldn't) own. Maybe it's a notification badge that has to poll every thirty seconds. Maybe it's an autocomplete box that fires a request per keystroke. Maybe it's a dashboard widget the user can refresh on demand without reloading the whole page. The moment you reach for useState and fetch inside a "use client" component to solve that, you've started down the path of client-side data fetching — and the App Router gives you real options for how to do it well.
This is one of those areas where the official docs are correct but compressed. They tell you what the three fetching patterns are and which cache layers exist, but they don't spend much time on the question most people actually have: which one do I reach for, and why does it matter which layer my data lives in? That's what this article is for. We'll walk through when you need a client-fetching library at all, the three shapes that fetching can take, how Next.js's own caches interact with whatever library you pick, and how to keep a mutation from leaving stale data sitting around in three different places at once.
Do you need a client-fetching library at all?
This is the question worth asking before anything else, because the honest answer is "no" more often than people expect.
If a Client Component only needs to read server data once — on mount, no polling, no revalidation, no shared cache across five different components — you don't need SWR, TanStack Query, or Apollo Client. You need a Promise and React's use() hook. The Server Component fetches the data, hands the Client Component a Promise instead of the resolved value, and the Client Component unwraps it with use():
// app/dashboard/page.tsx
import { Suspense } from "react";
import { UserGreeting } from "./user-greeting";
export default function DashboardPage() {
const userPromise = getUser(); // not awaited — passed down as a Promise
return (
<Suspense fallback={<p>Loading user…</p>}>
<UserGreeting userPromise={userPromise} />
</Suspense>
);
}
// app/dashboard/user-greeting.tsx
"use client";
import { use } from "react";
export function UserGreeting({
userPromise,
}: {
userPromise: Promise<{ name: string }>;
}) {
const user = use(userPromise);
return <p>Welcome back, {user.name}.</p>;
}
No library, no client cache key, no extra bundle weight. This is the right default for a huge share of "Client Component needs data" situations, and it's worth internalizing because the temptation to install a fetching library out of habit is strong. Adding SWR to a component that never revalidates is like installing Redux to hold a single boolean — it works, but you're carrying machinery you'll never use.
Where this breaks down is anything that needs a shared browser cache. If three unrelated components on the page all want the same "current user" object, and you want a fetch in one of them to update the other two, use() on a one-off Promise won't do that for you — each component would need its own Promise, and none of them would know about the others. That's the actual trigger condition for reaching for a library: shared identity across components, background revalidation (on focus, on interval, on reconnect), request deduplication so five components asking for the same key only trigger one network call, and optimistic updates that need to roll back cleanly on failure.
SWR, TanStack Query, and Apollo Client all solve this by giving every piece of data a key — a string or array that identifies "this is the same data everywhere it's requested" — and maintaining a cache keyed by that identity, independent of which component happens to be mounted. That's the actual value proposition, and it's worth being clear-eyed about it before adding one of these to a project, because once it's there, it tends to become the default way data flows through the client side of the app.
The three fetching patterns
Once you've decided you do want client-side fetching (with or without a library), there's a second decision that's just as consequential: when does the first render of that data become available? The docs lay this out as a comparison table, and it's genuinely one of the more useful tables in the Next.js docs because it maps cleanly onto user-visible behavior:
| Pattern | SWR | TanStack Query | When data becomes available |
|---|---|---|---|
| Inline loading states | useSWR | useQuery | Browser request after hydration |
| Suspense loading states | useSWR with suspense: true | useSuspenseQuery | Browser request after hydration |
| Provided by the server | <SWRConfig fallback> | <HydrationBoundary> | Initial render or streamed from server |
Inline loading states are the pattern most people reach for first because it's the most explicit: the hook gives you back a data, isLoading, and error triplet, and you render whatever you want based on those three values, right where you're using the data.
"use client";
import useSWR from "swr";
const fetcher = (url: string) => fetch(url).then((res) => res.json());
export function NotificationBadge() {
const { data, isLoading } = useSWR(
"/api/notifications/unread-count",
fetcher,
{
refreshInterval: 30_000,
},
);
if (isLoading) return <span className="badge-skeleton" />;
return <span className="badge">{data.count}</span>;
}
This is the right choice when every component should own its own loading UI independently — a sidebar widget shouldn't block on a totally unrelated chart finishing its fetch. The tradeoff is that if you have ten of these scattered across a page, you get ten independent loading states popping in at ten different times, which can look chaotic if the components are visually related.
Suspense loading states solve exactly that problem by moving the "what do I show while loading" decision up to a <Suspense> boundary instead of down into each component:
"use client";
import { useSuspenseQuery } from "@tanstack/react-query";
export function AccountSummary() {
const { data } = useSuspenseQuery({
queryKey: ["account-summary"],
queryFn: () => fetch("/api/account/summary").then((res) => res.json()),
});
return <p>Balance: {data.balance}</p>;
}
// parent component
<Suspense fallback={<AccountSummarySkeleton />}>
<AccountSummary />
</Suspense>
Now several components that all suspend under the same boundary reveal together, once all of them are ready, instead of popping in independently. This is the pattern to reach for when you're deliberately coordinating a group of components — say, a whole dashboard section — so it reveals as one coherent unit rather than a cascade of individual skeletons.
Provided by the server is the odd one out in this table, because it's not really about when the browser requests data — it's about skipping that request for the very first render. A Server Component fetches the data and seeds it into the client library's cache before the component ever mounts on the client, using <SWRConfig fallback={{...}}> for SWR or <HydrationBoundary state={...}> for TanStack Query. The library then treats that seeded value exactly like a value it fetched itself — including being able to revalidate it in the background — which means the first paint has real data and everything after that behaves like normal client-side fetching. We'll come back to the "who owns the initial value" question in the next section, because it's more subtle than it looks.
Which of these three you pick isn't really a technical decision as much as a UX one: Suspense coordinates rendering (which things appear together), the library and its keys determine when requests fire and how they're deduplicated, and providing data from the server determines whether the first paint has to wait on the browser at all. Mixing all three within the same app is completely normal — the sidebar might use inline loading, the dashboard might use Suspense, and the initial page load might seed data from the server.
Cache Components and the three-layer cache stack
Here's where the docs page earns its keep, and where I think most people underestimate how many caches are actually involved once Cache Components is turned on. Providing initial data and caching that data on the server are two separate decisions — you can do either without the other — but if you do both, you end up with three cache layers holding related data at the same time:
| Layer | What it stores | Freshness control |
|---|---|---|
| Next.js server cache | Cached data and Server Component output | cacheLife revalidate and expire |
| Next.js client cache | React Server Component payloads for visited/prefetched routes | cacheLife stale |
| Client data-fetching library | Browser data under an SWR key or TanStack query key | The library's own revalidation options and mutations |
It's worth sitting with what this actually means in practice. Say you have a product page that fetches getProduct(id) inside a "use cache"-annotated Server Component, and a Client Component on that same page also fetches product data client-side with SWR for live stock-count updates. You now have:
- The server cache holding the cached result of
getProduct(id), governed by whatevercacheLifeprofile you attached to it (its ownrevalidate/expirewindows). - The client (router) cache holding the React Server Component payload for that route, which Next.js may have prefetched before the user even navigated there, governed by that same profile's
stalewindow. - The SWR cache, holding whatever the client fetch returned, governed by SWR's own
refreshInterval/revalidateOnFocus/etc. settings — completely independent of the other two.
None of these three layers know about each other by default. They can (and often should) have wildly different freshness windows — the server cache might refresh every ten minutes, the client fetch might poll every thirty seconds — and that's fine, as long as you're deliberate about it. The mistake I see most often is someone assuming that because they set a cacheLife profile on the server function, the client-side SWR hook pulling similar-looking data will "just know" to match that cadence. It won't. They're different caches, keyed differently, invalidated differently, and coordinating them is your job, not the framework's.
// lib/data.ts
import { cacheLife, cacheTag } from "next/cache";
export async function getProduct(id: string) {
"use cache";
cacheLife("minutes"); // server cache: revalidate on a `minutes` profile
cacheTag(`product-${id}`);
const res = await fetch(`https://api.example.com/products/${id}`);
return res.json();
}
// components/stock-indicator.tsx
"use client";
import useSWR from "swr";
export function StockIndicator({ productId }: { productId: string }) {
// Independent freshness policy — polls every 15s regardless of the server cache's profile
const { data } = useSWR(
`/api/products/${productId}/stock`,
(url) => fetch(url).then((r) => r.json()),
{ refreshInterval: 15_000 },
);
return <span>{data ? `${data.count} in stock` : "…"}</span>;
}
If your app also uses Next.js's built-in prefetching, that adds another wrinkle: a hovered <Link> can place a route's RSC payload into the client cache before the user even clicks, meaning the "client cache" layer above might already be populated by the time the component using SWR mounts. That's a feature, not a bug — but it means "when did this data get fetched" isn't always a straightforward question to answer by watching network requests alone. If you're debugging a staleness issue, check all three layers before assuming the bug is in your fetch logic.
Coordinating mutations across three caches
This is the part of the docs page that's genuinely easy to skim past and genuinely the part that will bite you in production if you do. Once you've accepted that there are (potentially) three cache layers holding related data, the natural next question is: what happens when the user changes that data?
The docs frame the division of responsibility like this:
- Server Components provide the initial data, scoped to the segment that owns it.
- The data-fetching library stores the browser value under a shared cache identity (its key).
- Mutations update the browser cache immediately, and invalidate the cached server data so the next render reads something fresh.
That middle point is the one to internalize: your mutation's job isn't just "send the write to the server." It's "update the client cache optimistically, and also tell the server cache it's stale, so the next time something reads through the server path, it doesn't hand back the pre-mutation value." Miss the second half of that and you get a genuinely confusing bug class: the UI you're looking at (driven by the client library) shows the new value immediately after a successful optimistic update, but the moment the user navigates away and back — hitting the server-rendered path again — the old value reappears, because nothing told the server cache to drop it.
Next.js gives you three tools for that server-side half, and which one you reach for depends on how urgently the new value needs to be visible:
| Method | Use when | Next server read |
|---|---|---|
updateTag(tag) | A Server Action must make its update visible immediately | Waits for fresh data |
revalidateTag(tag, 'max') | The update is passive, or stale data briefly is acceptable | Serves stale data while revalidating in the background |
revalidateTag(tag, { expire: 0 }) | A webhook or external system requires immediate expiration | Waits for fresh data |
updateTag is the one to use when the mutation and the read are both driven by the same user action in the same request — think "user submits a form, and the very next render must reflect it." revalidateTag(tag, 'max') is the softer option: fire-and-forget invalidation where a brief stale read is an acceptable tradeoff for not blocking on a fresh fetch. And revalidateTag(tag, { expire: 0 }) is for the case where something outside the request — a Stripe webhook, a CMS publish hook — needs to force the next reader to wait for genuinely fresh data, because you have no user-facing request to attach an optimistic update to in the first place.
Here's what a full mutation looks like when you thread all of this together — optimistic client update, server cache invalidation, and a rollback path if the write fails:
"use client";
import useSWR, { mutate } from "swr";
import { updateProductName } from "./actions";
export function ProductNameEditor({
productId,
currentName,
}: {
productId: string;
currentName: string;
}) {
const key = `/api/products/${productId}`;
async function handleSave(newName: string) {
// 1. Optimistically update the client cache immediately
await mutate(
key,
async (current: { name: string }) => {
try {
// 2. Invalidate the server cache so the next server read is fresh
await updateProductName(productId, newName);
return { ...current, name: newName };
} catch (err) {
// 3. Roll back — mutate's rollbackOnError handles restoring the previous value
throw err;
}
},
{
optimisticData: { name: newName },
rollbackOnError: true,
revalidate: false,
},
);
}
return <NameForm initialValue={currentName} onSave={handleSave} />;
}
// app/actions.ts
"use server";
import { updateTag } from "next/cache";
export async function updateProductName(productId: string, name: string) {
await db.product.update({ where: { id: productId }, data: { name } });
updateTag(`product-${productId}`); // server cache: next read gets fresh data
}
Notice the docs' pointed caveat here, which is easy to miss but important: if the server read isn't cached in the first place, there's no server tag to invalidate. If you never wrapped getProduct in a "use cache" function with a cacheTag, calling revalidateTag or updateTag after a mutation does nothing meaningful — there's no cached entry for it to invalidate. This is a common source of "I revalidated but nothing changed" confusion: the revalidation call succeeded, it just had no cache to act on, because the read path it was supposed to invalidate was never cached to begin with.
Debugging "why is this stale" when three caches are involved
Once you accept that a single piece of data can be sitting in the server cache, the client router cache, and a fetching library's cache simultaneously, debugging staleness stops being a single-step process. I've lost real time to this, so here's the order I check things in now, before I assume the bug is in my fetch logic at all.
First, check the server cache. If the function reading the data is wrapped in "use cache", open dev tools' network tab and look at whether the server is even being asked — if the RSC payload for the route hasn't changed, the server cache is serving a cached render, and no amount of client-side mutate() calls will change what a fresh page load shows. This is usually the layer people forget exists, because it's invisible from the client — there's no client-side signal that a Server Component's output came from cache versus a live render.
Second, check the client router cache. If you've prefetched a route (hovering a <Link>, or Next.js prefetching it automatically), the RSC payload for that route may already be sitting in the browser, waiting to be used the instant the user navigates. That payload has its own stale window from cacheLife, independent of the server cache's revalidate/expire settings. If a value seems to update on a hard refresh but not on a client-side navigation, this is almost always the layer responsible.
Third, check the library's own cache. This is the one most people already know to check — is the SWR/TanStack Query key the same across the components you expect to share data? A surprisingly common bug is two components computing what look like "the same" key but with a subtly different shape (["product", id] vs ["product", String(id)]), which silently creates two separate cache entries instead of one shared one.
Working through these in order, outside-in from server to client, saves you from the instinct to immediately suspect the fetching library, when in a well-cached App Router app, the server-side layers are just as likely to be the culprit.
Where Apollo Client and GraphQL fit into this
The docs mention Apollo Client alongside SWR and TanStack Query, and it's worth a note on why it's grouped with them even though GraphQL clients are often talked about as a different category of tool. Functionally, Apollo's useQuery hook behaves the same way as useSWR or useQuery from TanStack Query for the purposes of everything above: it has a cache keyed by query + variables, it supports both inline and Suspense-driven loading states (useSuspenseQuery in Apollo Client 3.8+), and mutations go through useMutation with refetchQueries or update functions playing the same role that mutate()'s optimistic-update options play in SWR.
The one meaningful difference is that Apollo's cache is normalized by default — it stores individual entities by ID and stitches them back together for each query, rather than storing one blob per query key. That means a mutation that updates a single field on a Product entity can automatically update every query anywhere in the app that happens to include that product, without you manually invalidating each query key. If your data model has a lot of shared entities referenced from many different views — a user object that shows up in a header, a profile page, and a comments list, say — that normalized cache can save you from writing a lot of manual invalidation logic that SWR or TanStack Query would otherwise require you to write by hand.
The tradeoff is that Apollo assumes a GraphQL backend, whereas SWR and TanStack Query are transport-agnostic — they'll happily wrap a REST call, a GraphQL call, or a plain function that reads from localStorage. If your API is already GraphQL, Apollo's normalized cache is a real advantage worth considering. If it isn't, adopting Apollo just to get a data-fetching layer for REST endpoints is usually more machinery than the problem calls for.
A note on testing
One thing the docs don't cover at all, and that's worth planning for early: client-fetching libraries make components harder to unit-test in isolation, because now every component that calls useSWR or useQuery needs a cache provider in its test tree, or the hook throws. For SWR, that means wrapping test renders in <SWRConfig value={{ provider: () => new Map() }}> to give each test a fresh, isolated cache; for TanStack Query, it means constructing a new QueryClient per test and wrapping renders in <QueryClientProvider client={testClient}>.
The failure mode I've seen most often is a test suite where every test shares one QueryClient instance, and cache entries from an earlier test leak into a later one — a component that "should" show a loading state during a test renders cached data instead, because a previous test already populated that same query key. Instantiate a fresh client per test file (or per test, if your suite is fast enough to afford it), and this class of flaky test mostly disappears.
Putting it together: a decision framework
Rather than restate the docs' bullet points, here's the mental checklist I actually use when a new piece of client-side data shows up in a project:
- Does this data need a shared cache across components, or does one component own it exclusively? If it's exclusive and doesn't revalidate, skip the library — pass a Promise and use
use(). - Does the first paint need this data, or can it wait for hydration? If it needs to be there on first paint, seed it from a Server Component (
<SWRConfig fallback>or<HydrationBoundary>). If it can wait, a plainuseSWR/useQuerycall after mount is simpler and one less thing to keep in sync. - Should this component's loading state be independent, or coordinated with siblings? Independent → inline (
useSWR/useQuery). Coordinated → Suspense (useSWR({ suspense: true })/useSuspenseQuery). - Is the underlying data also cached on the server with
"use cache"? If yes, tag it withcacheTag, and make sure every mutation path that changes it also callsupdateTagorrevalidateTagwith that same tag. If no, the client library's cache is the only cache that matters, and you don't need to think about server-side invalidation at all.
Answering these four questions up front will save you from the two most common failure modes I see in real projects: over-engineering a one-off read with a full fetching library, and under-engineering a shared, cached value by forgetting that a client-side optimistic update doesn't automatically tell the server cache anything happened.
Key Takeaways
| Situation | What to reach for |
|---|---|
| One-off read, no shared cache needed | Pass a Promise, unwrap with use() — no library |
| Shared cache across components | SWR, TanStack Query, or similar |
| Independent per-component loading UI | useSWR / useQuery |
| Coordinated loading across several components | useSWR({ suspense: true }) / useSuspenseQuery |
| First paint needs the data | Seed from a Server Component (SWRConfig fallback / HydrationBoundary) |
| Mutation must be visible immediately | updateTag(tag) |
| Mutation can tolerate brief staleness | revalidateTag(tag, 'max') |
| External event forces immediate freshness | revalidateTag(tag, { expire: 0 }) |
Client-side data fetching in the App Router isn't one pattern — it's a set of independent decisions (library or not, when the first render happens, how loading states are coordinated, whether the server cache needs to know about mutations) that happen to get bundled together under one docs page. Treat them as separate questions, and the "how do these three caches interact" problem stops being mysterious and starts being just another thing you configure deliberately, the same way you'd configure any other cache.


