The Next.js 15 caching model that finally clicked
Next.js 15 caching as one mental model: Request Memoization, Data Cache, Full Route Cache, Router Cache, and tag-based revalidation that actually fires.
On this page
- The four caches, named by where they live
- The Next.js 15 change everyone tripped over: fetch is no longer cached by default
- Lever 1: static vs dynamic, and the force-dynamic escape hatch
- Lever 2: tag-based invalidation, the lever that actually scales
- revalidateTag vs revalidatePath
- The gotcha that wastes an afternoon: the client Router Cache
- The model, in five lines
- FAQ
Every App Router developer hits the same wall: you ship a change, the page doesn't update, you mash refresh, and nothing. Or the inverse, you wanted a page cached and it's hitting the database on every request. Next.js 15 caching feels like four invisible systems fighting each other, because that's exactly what it is. Once I stopped treating it as one magic box and started naming the four caches by what they hold, where they live, and what busts them, the whole thing collapsed into something I can reason about in my head.
Here's the thesis: Next.js caching is four separate caches at two locations, and almost every "why didn't it update" bug is you looking at the wrong one. This is the mental model, the Next.js 15 default that changed under everyone, and the practical levers I actually pull on real projects.
The four caches, named by where they live
Stop thinking "the cache." There are four, and they stack in this order on a request:
- Request Memoization — server, single render. Dedupes identical
fetchcalls within one React render pass, so two components asking for the same URL hit the network once. Lives and dies with that one request. You almost never touch it directly. - Data Cache — server, persistent across requests and deploys. Stores the result of a cached
fetchor cached function. This is the one you revalidate with time or tags. - Full Route Cache — server, build/render time. Caches the rendered HTML and RSC payload of a statically rendered route. If a route is static, this is why it's instant.
- Router Cache — client, in-memory, per session. The browser holds RSC payloads of visited routes so back/forward and
<Link>navigations feel instant without a server round-trip.
The two server caches (Data Cache, Full Route Cache) persist across users. The Router Cache lives in one user's browser tab. Confusing those two is the single most common caching bug: you revalidateTag on the server, the data updates, and you swear it's broken, because you're staring at a stale client Router Cache the server invalidation never touched.
Browser ┌─ Router Cache (RSC payloads, this tab only)
│
Server ├─ Full Route Cache (rendered HTML + RSC, static routes)
├─ Data Cache (fetch results, survives deploys)
└─ Request Memoization (dedupe within one render)
When you remember where each cache lives, "why is it stale" answers itself: figure out which layer is serving the stale bytes, then bust that one.
The Next.js 15 change everyone tripped over: fetch is no longer cached by default
If you learned the App Router on Next.js 13 or 14, burn this into memory: in Next.js 15, fetch() is no longer cached by default. Under 14, a bare fetch was implicitly cache: 'force-cache' — it silently entered the Data Cache, and a generation of developers got mystified by data that never updated. Next.js 15 reversed that: an uncached fetch is now treated as dynamic (effectively cache: 'no-store'), runs every request, and you opt into caching.
// Next.js 15: dynamic by default — runs every request.
const live = await fetch('https://api.example.com/now');
// Opt IN to the Data Cache, with a revalidation window and a tag.
const cached = await fetch('https://api.example.com/config', {
next: { revalidate: 3600, tags: ['site-config'] },
});
This default flip is the right call. The old behavior optimized for a benchmark and surprised everyone in production; the new one is honest — caching is a decision you make, not a thing that happens to you. The trade-off: a naive 14-to-15 migration can quietly get slower, because fetches that used to be cached now aren't. The fix isn't reverting; it's tagging the fetches that should be cached, which you wanted to do anyway.
One caveat: this default is about fetch. The Full Route Cache still tries to statically render routes at build time. A route with only cached or static data stays static; the moment you read something dynamic — cookies(), headers(), searchParams, or an uncached fetch — Next.js switches that route to dynamic rendering. Which brings us to the levers.
Lever 1: static vs dynamic, and the force-dynamic escape hatch
The biggest performance decision is per route: static (rendered once, served from the Full Route Cache) or dynamic (rendered per request). Next.js infers it, but you can force it.
For pages that must reflect the live database on every load — anything behind auth, anything per user — I make the decision explicit instead of trusting inference:
// app/admin/page.tsx
export const dynamic = 'force-dynamic';
This is exactly what the admin pages on this portfolio use. An admin dashboard reading session-scoped, constantly changing data has no business being cached — force-dynamic opts the whole route out of the Full Route Cache and renders fresh every time. The trade-off is intentional: you give up static-HTML speed in exchange for correctness on data that changes per request. For a login-gated dashboard, that's not even a trade; caching it would be a bug.
The mirror image is export const dynamic = 'force-static', and export const revalidate = <seconds> sets a time-based ISR window on the route. But time-based revalidation is the blunt instrument. The sharp one is tags.
Lever 2: tag-based invalidation, the lever that actually scales
Time-based revalidate is a guess: "this is probably fine for 60 seconds." Tag-based invalidation is a fact: "this changed now, bust it now." You attach a tag to cached data, then call revalidateTag(tag) the instant the underlying data changes — from a server action or a webhook.
This is the pattern I lean on hardest. On the Kat-Katha NGO giving platform, the public campaign pages are cached for speed — thousands of donors can hit them with near-zero database load. But a campaign's progress bar has to jump the moment a donation is captured. The solution is a per-type ISR cache tag that the Razorpay settlement webhook busts.
The public read tags its fetch by campaign type:
// Public, cached read — fast for everyone, tagged for precise busting.
async function getCampaigns(type: string) {
const res = await fetch(`${API}/campaigns?type=${type}`, {
next: { tags: [`campaigns:${type}`] },
});
return res.json();
}
Then the payment webhook, after it has idempotently recorded the captured payment in the ledger, invalidates only that campaign type's tag:
// app/api/webhooks/razorpay/route.ts (signature already verified)
import { revalidateTag } from 'next/cache';
export async function POST(req: Request) {
const event = await verifyAndParse(req); // HMAC-checked
if (event.type === 'payment.captured') {
const { campaignType } = await settle(event); // write the ledger
revalidateTag(`campaigns:${campaignType}`); // bust just this tag
}
return Response.json({ ok: true });
}
The result: public reads stay cached and cheap, and a captured payment refreshes the progress bar within about a second — because the webhook that already knows something changed is the thing that busts the cache. No polling loop, no websocket, no 60-second guess. A donation to one campaign type doesn't needlessly invalidate the others; the per-type tag keeps invalidation surgical. Getting that webhook to fire exactly once — and not double-count on Razorpay's at-least-once retries — is its own fight, which I wrote up in hardening Razorpay payment webhooks with idempotency. The same idempotent settlement pattern also backs checkout on Trendverse.
One forward note: Next.js 16 evolves this API —
revalidateTaggains a secondprofileargument (e.g.'max') and a siblingupdateTagfor immediate, in-action updates. On the Next.js 15 model above, the single-argumentrevalidateTag(tag)is the canonical call, so that's what I'm using here.
revalidateTag vs revalidatePath
Both invalidate the Data Cache and the relevant route cache; they differ in granularity:
revalidateTag('campaigns:art')— busts everything tagged with that label, wherever it's used. Surgical. Use it when the data changed and you tagged it.revalidatePath('/campaigns')— busts a specific route's cache. Coarser. Use it when you think in URLs, not data — e.g. after editing a single CMS page.
My rule: tag the data, revalidate the tag. Reach for revalidatePath only when there's a clean one-route-to-one-change mapping. Tags compose; paths don't.
The gotcha that wastes an afternoon: the client Router Cache
You did everything right — tagged the fetch, busted the tag, confirmed fresh data in an incognito window — and the original tab still shows stale content. That's the client-side Router Cache, and server invalidation can't reach into a user's browser.
Calls to revalidateTag and revalidatePath inside a server action also refresh the client Router Cache for that navigation, which is why mutations-via-server-actions mostly just work. The trap is invalidating from a route handler (like a webhook): that busts the server caches but leaves an already-loaded tab holding stale RSC. For data driven by external events, the honest fix is to not over-cache on the client — or to trigger a router.refresh() when you genuinely need an open tab to re-pull. Know which cache you're fighting before you fight it.
The model, in five lines
- Four caches, two homes. Request Memoization, Data Cache, and Full Route Cache live on the server; the Router Cache lives in the browser. Name the layer before you debug it.
- Next.js 15 made
fetchdynamic by default. Opt into caching withnext: { revalidate, tags }; don't expect 14's silentforce-cache. force-dynamicfor anything per-user or auth-gated. Admin dashboards should never be statically cached — that's a bug, not a perf win.- Tag the data, revalidate the tag.
revalidateTagis surgical and event-driven; time-basedrevalidateis a guess;revalidatePathis for URL-shaped changes. - Server invalidation can't touch a loaded browser tab. When "it's still stale," check whether you're staring at the client Router Cache.
Next.js caching stops being magic the moment you stop calling it "the cache." It's four caches — so ask which one is lying to you, and go bust exactly that.
Frequently asked questions
- Is fetch cached by default in Next.js 15?
- No. In Next.js 15, an uncached fetch is dynamic by default and runs on every request, effectively cache: 'no-store'. This reversed the Next.js 14 default, where a bare fetch was implicitly force-cache. To cache a fetch now, opt in explicitly with next: { revalidate } or next: { tags }.
- What are the four caches in the Next.js App Router?
- Request Memoization (dedupes fetches within a single render), the Data Cache (stores fetch results across requests and deploys), the Full Route Cache (caches rendered HTML and RSC for static routes), and the client-side Router Cache (holds RSC payloads in the browser for instant navigation). The first three live on the server; the Router Cache lives in the user's browser.
- What's the difference between revalidateTag and revalidatePath?
- revalidateTag busts every cache entry tagged with a given label, wherever that data is used, so it's surgical and best when the underlying data changed. revalidatePath busts the cache for a specific route URL, so it's coarser and best when one change maps cleanly to one page. The rule of thumb: tag your data and revalidate the tag.
- Why is my Next.js page still showing stale data after revalidateTag?
- You're most likely looking at the client-side Router Cache, which server-side invalidation can't reach. Calling revalidateTag inside a server action also refreshes the Router Cache for that navigation, but invalidating from a route handler or webhook only clears the server caches — an already-loaded browser tab keeps its stale RSC until a router.refresh() or a hard reload.
Hardening a Razorpay integration in Next.js: checkout vs webhook signature verification, idempotent settlement with a Postgres ledger, and the operational guards.
ReadPreventing double-bookings in Postgres: how a check-then-insert race oversells slots, and the layered fix — unique constraint, row and advisory locks, transactions, isolation.
Read