CodeOath
← All posts
Next.js31 min total · 12 parts

Next.js Fundamentals: The App Router, Server Components, and the Caching Layer Nobody Warns You About

Part 6 of 12 · ~4 min

Data Fetching & Caching

This is where most Next.js confusion genuinely lives, and it deserves to be treated precisely rather than hand-waved. In a Server Component, you don't reach for a data-fetching library by default — you call fetch directly, and Next.js extends the standard fetch with its own caching behavior layered on top:

async function getPosts() {
  const res = await fetch("https://api.example.com/posts");
  return res.json();
}

Here is the specific, easy-to-miss part: by default, fetch calls inside a Server Component are cached indefinitely at build/deploy time, and reused across every subsequent request, until something explicitly invalidates them. This is not "caches for a while like a browser cache." Left at its default, that same fetch("https://api.example.com/posts") call can serve the exact same response to every single visitor, indefinitely, having only actually hit the network once. Someone coming from plain React — where fetch inside a useEffect hits the network fresh, on every mount, every time — reasonably expects the same here, and gets bitten hard by data that appears "stuck," because it's not that the app is broken, it's that the cache is doing exactly what it was configured to do by default.

You control this per call with the cache option:

// Cached indefinitely (until manually revalidated) — the default if omitted
fetch(url, { cache: "force-cache" });

// Never cached — fetched fresh on every single request, closest to plain React's behavior
fetch(url, { cache: "no-store" });

Time-based revalidation

For data that should refresh periodically without needing to be fully dynamic, next.revalidate sets a maximum age in seconds:

// Serve the cached response for up to 60 seconds, then fetch fresh data
// on the next request after that window and update the cache
fetch(url, { next: { revalidate: 60 } });

This is stale-while-revalidate, not "block for 60 seconds then refetch": the request that finally falls outside the 60-second window still gets the stale cached response immediately, while Next.js kicks off a fresh fetch in the background to update the cache for the next request. No single user is ever made to wait on the revalidation itself.

On-demand revalidation

For data that changes in response to a specific event — someone published a new blog post, an admin edited a price — waiting on a timer is the wrong tool. On-demand revalidation invalidates a cache entry immediately, typically triggered from inside a Server Action or a Route Handler right after the mutation that made the old data stale:

"use server";
import { revalidatePath, revalidateTag } from "next/cache";

export async function publishPost(id: string) {
  await db.post.update({ where: { id }, data: { published: true } });
  revalidatePath(`/blog/${id}`);     // invalidate the cache for one specific route
  revalidateTag("posts");            // invalidate every fetch tagged "posts", wherever it's called from
}

revalidateTag pairs with tagging a fetch call up front, which is what makes it powerful across a codebase — you invalidate by meaning ("posts changed"), not by having to know every route that happened to fetch that data:

fetch("https://api.example.com/posts", { next: { tags: ["posts"] } });

Why this trips up nearly everyone at first

Coming from...ExpectsWhat actually happens by default
Plain React + useEffectFresh network request on every mountfetch in a Server Component is cached indefinitely unless told otherwise
A typical REST clientCache headers control cachingNext.js's own cache/next.revalidate options sit on top of (and can override) whatever cache headers the API itself sends
Client-side data libraries (SWR, React Query)Explicit, visible cache configuration per hookThe cache here is implicit — silent unless you go looking for the cache/next options on the call site

The concrete mental model to hold onto: every single fetch call in a Server Component makes its own independent caching decision, right there at the call site, and the default is "cache this forever." There's no single global switch — you decide per call whether it's force-cache (default), time-based (revalidate: N), tag-based for targeted on-demand invalidation, or fully dynamic (no-store). Getting a page to actually reflect new data almost always traces back to one specific fetch call still sitting on the wrong setting for what that data actually needs.