Next.js75 min total · 12 parts
Next.js Fundamentals: The App Router, Server Components, and the Caching Rules That Just Flipped
Part 6 of 12 · ~5 min
Data Fetching & Caching
More confusion clusters here than anywhere else in the App Router, and it deserves an exact answer rather than a general gesture — especially since the default here changed in Next.js 15 and hasn't changed back in 16, the exact version this project runs. A great deal of what's still ranking on Google about this topic describes the old default, so it's worth knowing both versions, because you'll run into code written under either one.
Inside a Server Component, you reach straight for fetch rather than pulling in a client-side data library:
async function getJobs() {
const res = await fetch("https://api.openroles.example/jobs");
return res.json() as Promise<Job[]>;
}
Here's the exact, current rule: since Next.js 15, and still true in 16, a bare fetch call inside a Server Component isn't cached at all unless you ask it to be. Leave the options off and that call goes back to the network — your API, your database, whatever's behind it — on every single request. That's the reverse of what it used to do, and the reverse of what a lot of still-popular writing on this topic teaches.
Watch how that actually bites OpenRoles. getJobs() above, written with no options at all, looks like the kind of function nobody would think twice about. Then OpenRoles gets linked from somewhere with real traffic. Every visitor to the homepage now triggers that exact same database query from scratch, because nothing ever told Next.js to hold onto the result. Postgres, which had been idling along fine under normal load, pegs its CPU and starts dropping connections — not because the job listings changed underneath anyone, but because none of this was ever cached to begin with, so a burst of visitors is just a burst of identical, wasted queries.
That's the honest shape of the surprise today, and it runs against what a lot of people arrive expecting — including anyone who learned this from material written for Next.js 13 or 14, back when the default really did run the other way.
| Coming in from... | Expects | What Next.js 16 actually does |
|---|---|---|
| A guide or tutorial written before late 2024 — plenty of it still ranks well | fetch cached indefinitely unless you opt out | Flipped since 15: nothing gets cached unless you opt in |
Plain React with useEffect | A fresh request on every mount | True here too now, coincidentally — but the cost lands on your server, not just on bandwidth |
| A standard REST client | Caching follows whatever headers the API sends | Next's own cache and next.revalidate options decide, layered on top of anything the API's headers say |
Hold onto this: nothing decides caching for a fetch except the options sitting right next to it, in that one call. Leave them off, and you've made the decision by default — no caching, no exceptions, no matter how many times that same request runs. Opting in is something you do explicitly, one call at a time:
// Held onto until something explicitly clears it
fetch(url, { cache: "force-cache" });
// Explicitly not cached — functionally the same as leaving cache unset
fetch(url, { cache: "no-store" });
Giving cached data a shelf life
Some data can tolerate being a few minutes behind — a job listing doesn't need to reflect a brand-new posting within milliseconds of it going live. next.revalidate sets that shelf life, in seconds, before the next request triggers a refresh:
// Keep serving the cached copy for up to five minutes, then
// refresh it on whichever request lands after that window closes
fetch(url, { next: { revalidate: 300 } });
This behaves as stale-while-revalidate, not "pause for five minutes and then fetch": whoever's request happens to land right after the window closes still gets the old, cached copy back immediately, while Next.js quietly kicks off a fresh fetch behind the scenes to update things for the next request after that. Nobody sits there waiting on the actual revalidation.
Clearing the cache exactly when something changes
A timer is the wrong instrument for "an employer just posted a job, and it needs to show up now." Tag the fetch up front so you can target it by what it means, not by which URL happened to request it:
fetch("https://api.openroles.example/jobs", {
next: { tags: ["jobs"] },
});
Then clear that tag the moment whatever it depends on actually changes — right inside the Server Action that publishes the job, immediately after the write:
"use server";
import { revalidateTag } from "next/cache";
export async function createJob(formData: FormData) {
await db.job.create({ /* ... */ });
revalidateTag("jobs", "max"); // every fetch tagged "jobs", no matter where it's called from
}
Notice that second argument on revalidateTag — a profile name like "max", "hours", or "days". That's its own small piece of version drift worth calling out on its own: it's a Next.js 16 addition. Older code, and plenty of still-published guides, call revalidateTag("jobs") with just the one argument — it still runs, but 16 wants that profile alongside it to control how the stale-while-revalidate window actually behaves for that tag.
Put those two pieces together and here's exactly how OpenRoles gets out of its own database-hammering story: cache the homepage's getJobs() call, tag it "jobs", keep a generous revalidate window as a backstop, and fire revalidateTag("jobs", "max") from inside createJob the instant a listing goes live. The homepage now serves out of cache under a traffic spike and reflects a brand-new posting immediately — not from a clever timer, but because the one write that actually matters is the one thing that clears it.
Two more tools exist here worth knowing about, without OpenRoles needing to lean on either: unstable_cache wraps a non-fetch async function — a raw ORM query, say — the same way fetch's own options wrap a network call, for the very common case of reading straight out of Postgres instead of calling an external API. And Next.js 16 ships a genuinely newer caching model on top of all this — Cache Components, centered on a "use cache" directive — that flips this exact default at the language level rather than per fetch call. It's real, it's shipped, and it's off unless a project opts in through next.config.ts. OpenRoles, like most Next.js 16 apps currently in production, hasn't turned it on, which is why this chapter sticks to the fetch-option model above.