Type something to search...
Client-side data fetching with SWR

Client-side data fetching with SWR

Not everything in a Next.js App Router project belongs on the server. A product search box that fires a new request on every keystroke, a notification badge that polls every thirty seconds, a live dashboard widget the user toggles on and off — these are all things that live in the browser, react to browser state, and have no business round-tripping through a Server Component re-render. For that class of problem, SWR is still one of the best tools available, and it happens to integrate with the App Router in ways that go well beyond "just call useSWR in a Client Component."

This article walks through that integration end to end: fetching purely in the browser, choosing between inline and Suspense loading states, seeding SWR with data from a Server Component so the first render isn't blank, and — new territory if you haven't touched Cache Components yet — wiring SWR's browser cache together with Next.js's server cache so a mutation invalidates both at once. None of this is exotic once you've seen it, but the pieces don't obviously fit together the first time you go looking.

Why client-side fetching still matters in the App Router

It's easy to read "Server Components fetch data" and conclude that client-side fetching is a legacy pattern the App Router wants you to retire. That's not quite right. Server Components are the default for data that's known at request time and doesn't change based on what the user types or clicks next. But a lot of real UI doesn't fit that shape:

  • Autocomplete and search-as-you-type. The query doesn't exist until the user starts typing, so there's nothing to render on the server for it.
  • Polling and live updates. A notification count that refreshes every thirty seconds needs a client-side interval, not a server round trip.
  • User-triggered refreshes. A "check for updates" button, a pull-to-refresh gesture, anything the user explicitly asks for after the page has already loaded.
  • Data that depends on client-only state. Scroll position, viewport size, a value read from localStorage — none of which the server can see.

SWR (and TanStack Query, which does much the same job with a different API) exist because "fetch this thing again when some browser event happens, cache it, dedupe concurrent requests for the same key, and give me loading/error states" is a surprisingly large amount of logic to hand-roll with useEffect and useState. If you've ever tried to build a race-condition-free autocomplete component by hand, you know exactly how much SWR is quietly doing for you.

The choice between the pattern in this article and the general "client-side data fetching" overview isn't really "which one is correct" — it's about whether the initial view can tolerate waiting for a browser round trip after hydration, or needs to show real data immediately. That distinction is what the rest of this article is built around.

Pattern 1: fetch entirely on the client

The simplest case is a view where there's genuinely nothing to render until the browser gets involved — an autocomplete box is the canonical example, since the query string doesn't exist until someone starts typing.

// app/product-autocomplete.tsx
"use client";

import useSWR from "swr";

type Product = { id: string; name: string };

async function fetcher(url: string): Promise<Product[]> {
  const response = await fetch(url);
  if (!response.ok) throw new Error("Failed to fetch products");
  return response.json();
}

export function ProductAutocomplete({ query }: { query: string }) {
  const {
    data = [],
    error,
    isLoading,
  } = useSWR(
    query ? `/api/products?query=${encodeURIComponent(query)}` : null,
    fetcher,
  );

  if (!query) return null;
  if (error) return <p>Failed to load products.</p>;
  if (isLoading) return <p>Loading products...</p>;

  return (
    <ul>
      {data.map((product) => (
        <li key={product.id}>{product.name}</li>
      ))}
    </ul>
  );
}

The detail worth pausing on is the conditional key. Passing null as the key tells SWR "there is no request to make right now." As soon as query becomes truthy, the key changes to a real URL string and SWR fires the request. This is the standard SWR idiom for "don't fetch until I have something to fetch," and it's the reason you don't need a useEffect with an if (!query) return guard scattered through your component — the key itself is the guard.

Notice too that data defaults to an empty array. Without that default, data is undefined on the very first render (before any request has resolved), and data.map(...) would throw. This is a small thing, but it's the single most common runtime error people hit the first time they wire up useSWR — an "cannot read properties of undefined" error that has nothing to do with SWR itself and everything to do with forgetting that the first render has no data yet.

This component owns its own loading and error states inline, rendering a <p>Loading products...</p> directly in place of the list. That's the right call when the loading UI is small and local — a spinner replacing a dropdown, say. It's the wrong call when several sibling components are all loading independently and you'd rather coordinate them under one shared fallback, which is where Suspense comes in.

Pattern 2: let Suspense own the loading state

If you'd rather the nearest <Suspense> boundary define what "loading" looks like — instead of each component managing its own isLoading flag — set suspense: true:

// app/product-autocomplete.tsx
"use client";

import { Suspense } from "react";
import useSWR from "swr";

type Product = { id: string; name: string };

async function fetcher(url: string): Promise<Product[]> {
  const response = await fetch(url);
  if (!response.ok) throw new Error("Failed to fetch products");
  return response.json();
}

export function ProductAutocomplete({ query }: { query: string }) {
  if (!query) return null;

  return (
    <Suspense fallback={<p>Loading products...</p>}>
      <ProductResults query={query} />
    </Suspense>
  );
}

function ProductResults({ query }: { query: string }) {
  const { data } = useSWR(
    `/api/products?query=${encodeURIComponent(query)}`,
    fetcher,
    { suspense: true },
  );

  return (
    <ul>
      {data.map((product) => (
        <li key={product.id}>{product.name}</li>
      ))}
    </ul>
  );
}

A few things changed here that are worth naming explicitly. First, the conditional-key trick moved out of useSWR and into a plain if (!query) return null at the top of the outer component — Suspense doesn't have an equivalent of "don't fetch," so the component that decides whether to render the fetching child at all has to make that call itself, before the <Suspense> boundary is even reached. Second, data is no longer optional inside ProductResults — with suspense: true and an unconditional key, SWR guarantees data is defined by the time this component actually renders, because React won't render it until the promise resolves. That's the entire value proposition of Suspense mode: it removes an entire category of "is my data here yet" branching from the component body, at the cost of needing an ancestor <Suspense> boundary and an error boundary to catch failures (since a rejected fetch throws, it doesn't set an error field the way non-Suspense mode does).

Two SWR flags are useful for understanding what's happening under a Suspense boundary once you're past the initial load:

  • isLoading is true only when a request is in flight and there's no data to show yet — this is what Suspense mode is built on.
  • isValidating is true any time a request is in flight, including a background revalidation of data that's already displayed.

Once the initial Suspense fallback has resolved once, a later revalidation for the same key does not re-suspend the component — the stale data stays on screen while the fresh request runs in the background. If you want a subtle "refreshing…" indicator during that background revalidation, isValidating is the flag to reach for, since isLoading won't tell you anything at that point.

One caveat worth calling out because it's easy to trip over: independent Suspense reads in sibling components can start in parallel, but multiple Suspense reads inside the same component run sequentially, one blocking the next. If you've got a component doing useSWR(keyA, ..., {suspense: true}) immediately followed by useSWR(keyB, ..., {suspense: true}), you've built yourself a network waterfall without meaning to — the fix is almost always to split those into sibling components so React can parallelize the requests.

Pattern 3: seed SWR from a Server Component

Both patterns above assume the first paint can be empty and the data can arrive after hydration. Often that's not acceptable — a product detail page shouldn't show a loading spinner where the product name should be if the server already knows what that product is. This is where SWR's fallback mechanism earns its keep: a Server Component fetches the data as part of rendering, and hands it to SWR as the initial value, so the client "continues" managing data that already exists rather than starting from nothing.

// app/products/[id]/page.tsx
import { Suspense } from "react";
import { SWRConfig } from "swr";
import { getProduct } from "./data";
import { productCache } from "./product-cache";
import { ProductView } from "./product-view";

export default function Page({ params }: PageProps<"/products/[id]">) {
  return (
    <Suspense fallback={<p>Loading…</p>}>
      {params.then(({ id }) => (
        <ProductData id={id} />
      ))}
    </Suspense>
  );
}

function ProductData({ id }: { id: string }) {
  return (
    <SWRConfig
      value={{
        fallback: {
          // Not awaited: only components that read this key suspend
          [productCache.key(id)]: getProduct(id),
        },
      }}
    >
      <ProductView id={id} />
    </SWRConfig>
  );
}
// app/products/[id]/product-cache.ts
export const productCache = {
  key: (id: string) => `/api/products/${id}`,
};
// app/products/[id]/product-view.tsx
"use client";

import useSWR from "swr";
import { productCache } from "./product-cache";

type Product = { id: string; name: string };

async function fetcher(url: string): Promise<Product> {
  const response = await fetch(url);
  if (!response.ok) throw new Error("Failed to fetch product");
  return response.json();
}

export function ProductView({ id }: { id: string }) {
  const { data } = useSWR(productCache.key(id), fetcher, { suspense: true });

  return <h1>{data.name}</h1>;
}

There's a lot packed into this small example, so it's worth unpacking piece by piece.

The key contract is the whole trick. <SWRConfig fallback> is keyed by the exact same string useSWR reads inside ProductView. If those two strings ever drift — a typo, one of them getting the ID interpolated in a slightly different format — SWR silently ignores the fallback and fetches from scratch on the client, with no error to tell you why. That's why the example pulls the key generation into a small shared object, productCache, instead of writing the template string twice. It's a tiny amount of ceremony that eliminates an entire category of "why is this flashing a loading state on first paint" bug reports.

getProduct(id) is deliberately not awaited. Notice that the fallback value is the Promise returned by getProduct(id), not its resolved value. This is intentional and it's the mechanism that makes the whole pattern non-blocking: React serializes that Promise across the Server Component boundary, and only the components that actually read the matching SWR key suspend while it resolves. If getProduct were awaited first, the entire ProductData component (and everything under it) would block on the database call before any HTML streamed at all — you'd lose the benefit of Suspense-based streaming entirely.

params.then(...) is playing the same role one level up. In newer Next.js versions params is itself a Promise, and awaiting it inside a Suspense boundary is what lets the outer <Suspense fallback={<p>Loading…</p>}> cover the time it takes to resolve the route parameters, before ProductData (and its own nested Suspense-driven fetch) even starts.

Fallback data is treated as stale by default. SWR doesn't know when getProduct(id) was computed relative to "now," so out of the box it treats the fallback as stale and kicks off a browser revalidation immediately after hydration — meaning the component you just carefully avoided a loading spinner for will, a moment later, silently refetch in the background. If that's not what you want — say, the data is genuinely fresh enough that a background refetch on every single page load is wasted work — set revalidateIfStale: false. Unlike TanStack Query's staleTime, which measures actual time elapsed, this SWR option is a blunt boolean: revalidate on every mount, or don't. Focus events, reconnect events, and manual mutate() calls all still trigger revalidation regardless of this setting.

The SWR key intentionally points to a real, GET-able URL. That's not incidental — it means the same getProduct function backing the Server Component fallback can be reused inside a Route Handler at that URL, so the browser's revalidation requests (on focus, on reconnect, on a polling interval) have a real endpoint to hit rather than needing some separate client-side-only fetch path.

Layering Cache Components on top

If your project has cacheComponents enabled, you can go one step further and cache the server-side data that feeds the SWR fallback, so getProduct isn't hitting the database on every single request:

// app/products/[id]/data.ts
import { cacheLife, cacheTag } from "next/cache";

export async function getProduct(id: string) {
  "use cache";
  cacheLife("max");
  cacheTag(`product:${id}`);

  const product = await db.product.findUnique({ where: { id } });
  if (!product) throw new Error("Product not found");
  return product;
}

The choice of cacheLife('max') here isn't arbitrary — it only makes sense because writes are going to explicitly invalidate the tag (more on that in a moment), so there's no need for a time-based expiry to catch staleness the tag system already handles. If your data changes on its own schedule rather than through explicit mutations you control, pick a shorter, time-based cacheLife profile instead.

It's worth being clear-eyed about the fact that this is now two independent caches, not one. SWR's browser cache and the use cache server cache don't know about each other and don't need to agree on freshness windows — SWR's revalidation options apply to what the browser does with data it already has; cacheLife's stale/revalidate/expire fields apply to how long the server considers a cached function call good before recomputing it. Treating them as one unified cache is a mental model that will only cause confusion. Treat them as two caches that happen to be connected at one seam: the tag.

Coordinating a mutation across both caches

That seam is where things get genuinely useful. When a Server Action changes data that both SWR and the server cache care about, you want one action to invalidate both — and you want the UI to feel instant while that happens, via an optimistic update.

Start by giving the shared identity (URL key + cache tag) a single home, so nothing can drift out of sync between the two systems:

// app/activity/activity-cache.ts
export const activityCache = {
  key: "/api/activity/unread",
  tag: (userId: string) => `activity:${userId}`,
};

The client side reads and mutates through useSWRConfig, using optimisticData so the button feels instant rather than waiting on a round trip:

// app/activity/mark-read-button.tsx
"use client";

import { useSWRConfig } from "swr";
import { markActivityReadAction } from "./actions";
import { activityCache } from "./activity-cache";

export function MarkReadButton() {
  const { mutate } = useSWRConfig();

  function markRead() {
    return mutate(
      activityCache.key,
      async () => {
        await markActivityReadAction();
        return { count: 0 };
      },
      {
        optimisticData: { count: 0 },
        revalidate: false,
        rollbackOnError: true,
        throwOnError: false,
      },
    );
  }

  return <button onClick={markRead}>Mark read</button>;
}

And the Server Action does the actual write, then expires the server-side tag so the next cached read reflects the change:

// app/activity/actions.ts
"use server";

import { updateTag } from "next/cache";
import {
  getCurrentUserId,
  markActivityRead as markActivityReadInDatabase,
} from "./data";
import { activityCache } from "./activity-cache";

export async function markActivityReadAction() {
  const userId = await getCurrentUserId();
  await markActivityReadInDatabase(userId);
  updateTag(activityCache.tag(userId));
}

Walk through what actually happens when someone clicks this button, because the ordering matters:

  1. mutate() immediately writes { count: 0 } into SWR's local cache for the activityCache.key entry. Every component reading that key re-renders with the optimistic value before the network request has even started.
  2. The async updater function runs, calling the Server Action.
  3. The Server Action writes to the database, then calls updateTag, which invalidates the cached server-side read for that tag.
  4. If the Server Action throws, rollbackOnError: true reverts SWR's cache back to whatever it held before step 1 — the optimistic update disappears and the UI snaps back, which is exactly the behavior you want for a failed write.
  5. revalidate: false tells SWR not to immediately refetch the key after the mutation resolves — you're trusting the optimistic value as the final value here, which is a reasonable choice when you already know exactly what the new state is (an unread count going to zero), but would be the wrong choice for a mutation whose result you can't predict client-side.

The one rule that's easy to violate without noticing: updateTag only does something if there was a cached read to invalidate in the first place. If a piece of data was never behind use cache, calling updateTag on some tag related to it is a no-op — there's nothing there to expire. This is the kind of bug that doesn't show up as an error; it shows up as "why isn't this updating," days later, when someone adds caching to a query path and forgets the corresponding action needs updating too.

Common mistakes worth naming

Forgetting the default on data. Covered above, but it bears repeating because it's the single most common first-fetch error: data is undefined until the request resolves in non-Suspense mode. Default it (data = [] or similar) or guard against undefined explicitly.

Letting keys drift between the fallback and the client read. If you write the URL template string in two places instead of one shared function, eventually someone will change one and not the other, and you'll get an unexplained extra fetch on first paint with no error message pointing at the cause.

Treating SWR's cache and the server's use cache as one cache. They're not. Configuring revalidateIfStale doesn't change how long the server caches getProduct, and setting a long cacheLife doesn't stop SWR from revalidating on window focus. Reason about them independently, and use the tag/key contract as the only place they're allowed to touch.

Firing a request on every keystroke with no debounce. SWR's conditional-key pattern controls whether a request fires, not how often. An autocomplete box wired directly to useSWR(query ? url : null, fetcher) with no debouncing will fire a network request on every keystroke — SWR will dedupe identical concurrent requests for you, but it won't stop you from generating a new request per character typed. Debounce the query value itself (with a small useDeferredValue or a manual timeout) before it ever reaches useSWR.

Reaching for SWR when a Server Component would do. If the data doesn't depend on anything only the browser knows, and there's no need to refetch it after the initial render, you don't need SWR at all — you need a Server Component. SWR earns its complexity when there's real client-side state (a search query, a polling interval, a manual refresh) driving the fetch.

Key Takeaways

SituationWhat to reach for
Initial view can be empty, data arrives after hydrationPlain useSWR with an inline loading/error state
Several components should share one loading UIuseSWR(..., { suspense: true }) under a <Suspense> boundary
Server already knows the data; avoid a blank first paintSWRConfig fallback seeded from a Server Component, read via a shared key
Server data should be cached and mutation-invalidateduse cache + cacheLife + cacheTag, tied to the SWR key via a shared cache-contract object
A mutation should feel instant and update the server cache toomutate(key, updater, { optimisticData, rollbackOnError }) on the client, updateTag in the Server Action

SWR earns its place in an App Router project exactly where Server Components can't reach: state that only exists in the browser, driving fetches that need to happen after the page has already loaded. The fallback pattern is what keeps that from meaning "every client-fetched view starts blank" — and the shared key/tag contract is what keeps two independent caching systems from quietly drifting apart the first time someone adds a mutation.

Tags :
Share :

Related Posts

Can Next.js Be Used with GraphQL?

Can Next.js Be Used with GraphQL?

Next.js and GraphQL are two powerful technologies that have gained significant traction in the web development community. Next.js, a React-based fram

Dive Deeper
How does Next.js differ from Create React App?

How does Next.js differ from Create React App?

In the world of modern web development, React.js has emerged as a dominant force due to its flexibility, performance, and extensive ecosystem. Two po

Dive Deeper
How does Next.js handle image optimization?

How does Next.js handle image optimization?

In modern web development, image optimization plays a critical role in enhancing user experience and improving site performance. Large, unoptimized i

Dive Deeper