Why is Next.js App Router caching always explained as a list of flags to memorize, and almost never as a decision about data? I've been watching posts go by for a while now — "just add no-store and you're done" or "set revalidate = 0 if you want fresh data" — and they work, in the sense that they put out the fire. But they never explain why there was a fire in the first place.
My thesis is simple: the problem isn't not knowing the flags. Most of the time, it's that nobody has figured out what freshness each piece of data actually needs before writing the component. When that's unclear, the flags become folklore.
The App Router caching model: what the official docs actually say
The official Next.js documentation describes four cache layers that operate independently:
- Request Memoization — deduplication of
fetchcalls within the same render tree - Data Cache — persistence across requests (the one that confuses everyone most)
- Full Route Cache — HTML and RSC payload cached on the server
- Router Cache — client-side prefetch in the browser
What the docs don't say explicitly is when each layer actually matters for you. They describe the mechanism. The design decision is yours.
The default in App Router is aggressive caching: fetch calls inside Server Components are cached in the Data Cache by default. A fetch with no explicit options behaves as if you wrote { cache: 'force-cache' }. That surprises a lot of people coming from Pages Router, where the default was no cache.
Here's the actual structure of options that control the Data Cache:
// Option 1: force cache (default in App Router if you don't specify anything)
const res = await fetch('https://api.example.com/data', {
cache: 'force-cache', // stores the response indefinitely until revalidation
})
// Option 2: no cache — always fresh, every request goes to the origin
const res = await fetch('https://api.example.com/data', {
cache: 'no-store', // never persists, never reads from cache
})
// Option 3: time-based revalidation — fresh every N seconds
const res = await fetch('https://api.example.com/data', {
next: { revalidate: 60 }, // ISR-style: refreshes every 60 seconds
})
// Option 4: on-demand revalidation (from a Route Handler or Server Action)
// revalidatePath('/route') or revalidateTag('my-tag')
// This invalidates from code, not by timeAt the route segment level, you can define the contract for the entire component using segment config exports:
// In any page.tsx or layout.tsx
// Entire segment as dynamic: equivalent to no-store on all fetches
export const dynamic = 'force-dynamic'
// Entire segment as static: equivalent to force-cache
export const dynamic = 'force-static'
// Time-based revalidation for the entire segment
export const revalidate = 300 // 5 minutesThe key thing: segment exports are a shortcut that affects the entire route. If you mix export const dynamic = 'force-dynamic' with fetch(..., { cache: 'force-cache' }) inside the same component, the segment config wins. That's a documented Next.js design decision — and it's also the source of most of the confusion I see.
The data contract: the question you need to ask first
The real friction isn't technical. It's product design. Before choosing any cache option, the right question is:
How stale can this data get before the user notices or something breaks?
That's the freshness contract. And it has different answers depending on the type of data:
| Data | Freshness contract | Recommended option |
|---|---|---|
| Product price in e-commerce | Never stale — affects conversion | no-store |
| Published blog article | Can be hours old — doesn't change often | revalidate: 3600 |
| Site config (colors, copy) | Can be days old | force-cache + revalidateTag on-demand |
| Available stock | Never stale — affects purchase decision | no-store |
| Category list | Can be days old — rarely changes | force-cache + deploy as trigger |
| User session | Never cacheable on shared server | no-store + cookie auth |
When you have a clear freshness contract, the flag is a consequence. When you don't, the flag is magic.
Where people go wrong: the three recipes that cost you
Recipe 1: force-dynamic across the entire app "just to be safe"
This is the equivalent of having no cache at all. I get the impulse — if everything is dynamic, you'll never serve stale data. The cost is that you lose Full Route Cache, you lose ISR, and every request hits all your origins. For a blog page with 10k daily visits, that has real impact on latency and infrastructure costs. I didn't make that number up — it's easy to reproduce with any load simulation against a Vercel or Railway deploy.
Recipe 2: mixing no-store and force-cache in the same component without understanding precedence
If a child component does a fetch with force-cache but the parent layout has export const dynamic = 'force-dynamic', the dynamic segment wins and the fetch never reaches the Data Cache. This isn't a bug — it's documented. But if you don't know it, you spend hours staring at why the cache "isn't working."
Recipe 3: assuming revalidate: 0 is the same as no-store
It's not. revalidate: 0 means revalidate on every request, which in practice behaves similarly to no-store for the Data Cache. But the Full Route Cache can still serve cached HTML depending on other segment conditions. no-store on the fetch is more explicit about intent. The docs distinguish between the two behaviors even if the practical difference is small in many cases — the right call is to use whichever one better describes your contract.
Decision checklist: how to choose without memorizing
Before writing the fetch or configuring the segment, answer these four questions:
1. Does this data change between requests from different users?
→ YES: it probably shouldn't be in shared cache → no-store
2. Does this data change on a predictable schedule (every X minutes/hours)?
→ YES: revalidate: <seconds>
3. Does this data only change when someone takes an explicit action (an editor publishes, an admin updates)?
→ YES: force-cache + revalidateTag() on-demand from a Server Action
4. Is this data completely static and will never change after deploy?
→ YES: force-cache with no revalidation (or just don't fetch — import the data)
This checklist doesn't replace judgment — it structures it. If you can't answer question 1 with certainty, that's a signal your data contract is undefined, not that you picked the wrong flag.
For on-demand revalidation, the revalidateTag pattern is especially useful when you have managed content (a CMS, an admin panel):
// In a Server Action that runs when an editor publishes
'use server'
import { revalidateTag } from 'next/cache'
export async function publishArticle(id: string) {
// ...save logic...
// Invalidate only the fetches that carry this tag
revalidateTag('articles')
revalidateTag(`article-${id}`)
}
// In the component that fetches the data:
const res = await fetch('https://api.example.com/articles', {
next: { tags: ['articles'] }, // associates the fetch with the tag for selective invalidation
})This pattern avoids the sledgehammer of revalidatePath('/') that invalidates everything. It's more surgical and cheaper in terms of renders.
If you want to understand how this caching model relates to the mental model of Server Components, I wrote about that in React 19 Server Components and caching: the mental model I was missing.
Gotchas that don't make it into five-minute tutorials
The Data Cache survives deploys on Vercel by default. This surprises people: if you deploy, the Data Cache doesn't get automatically invalidated unless you use revalidatePath, revalidateTag, or configure a short revalidate time. The docs mention it; tutorials skip it. If you update content on deploy, you need an explicit invalidation strategy.
Route Handlers have their own model. A GET handler in app/api/route/route.ts is dynamic by default unless you define export const dynamic = 'force-static' or export const revalidate. That's the opposite of behavior in page.tsx. Yes, it's confusing. It's documented, but it requires reading carefully.
Request Memoization only lives within a single render. If you call the same fetch in two different Server Components within the same request, Next.js deduplicates them automatically. That's memoization, not Data Cache. It clears at the end of each request. You can't rely on it to share data across different requests.
no-store on a fetch doesn't automatically make the route dynamic. If the segment has export const revalidate = 3600, that config can take precedence over the expected fetch behavior. The interaction between segment configs and fetch options has a precedence table in the official documentation that's worth reading once, in full.
On patterns for handling requests in Next.js Middleware, there's a separate analysis at Next.js 16 Middleware: authorization patterns that scale — caching and middleware interact in authentication scenarios in ways that deserve their own attention.
Limits: what you can't conclude without your own data
This matters. There are things this analysis can't tell you:
- How much time you save with ISR versus
no-storein your specific app. It depends on traffic, origin latency, and infrastructure. The only way to know is to measure it with your own logs. - Whether Full Route Cache is worth it for you if most of your pages have user-personalized data. In that case it probably doesn't apply — but I can't say that universally.
- How the Router Cache behaves in the browser under different network conditions. It's client-side and has its own prefetch logic that can make data look stale even when the server is revalidating correctly.
If you want to dig into how caching decisions affect tools that run on both client and server, the post on Web Crypto API in the browser vs Node.js has an interesting parallel about assuming uniform behavior where none exists.
FAQ
What's the difference between cache: 'no-store' and export const dynamic = 'force-dynamic'?
no-store applies to a single fetch. force-dynamic applies to the entire route segment (page, layout) and makes Next.js treat the whole route as dynamic. If you only need one fresh piece of data inside an otherwise static route, no-store on that fetch is more precise.
Does revalidate: 0 equal no-store?
In practice they behave similarly for the Data Cache, but they're not semantically equivalent. revalidate: 0 revalidates on every request; no-store never saves to cache at all. To express the intent of "never cache this," no-store is clearer. The official docs distinguish the two behaviors.
Does the Data Cache clear on every deploy?
On Vercel, not automatically. The Data Cache persists across deploys unless you explicitly invalidate it with revalidatePath, revalidateTag, or let the revalidate time expire. This matters for content you update with each deploy.
Can I mix force-cache and no-store in the same component?
Technically yes, but segment config takes precedence over individual fetch options. If the segment says force-dynamic, all fetches in the component behave as dynamic regardless of what you pass in the cache option. Check the precedence table in the documentation.
Does revalidateTag work if the fetch is deep in a child component?
Yes, as long as the fetch used next: { tags: ['my-tag'] }. The tag associates the fetch with a global invalidation key. How deep the component sits in the tree doesn't matter for invalidation.
When should I use revalidate at the segment level versus the fetch level?
Segment level makes sense when all the data in the route has the same freshness contract. If the route has data with different lifecycles (something static and something that changes every hour), it's better to control each fetch individually so you're not invalidating more than necessary.
My take, without the folklore
Next.js App Router has a genuinely powerful caching system. It also has enough interaction complexity that "just add no-store and you're done" is the most common answer on Stack Overflow — and technically it's correct, but it's the equivalent of turning off the heat when you're hot instead of opening a window.
What I find valuable to do before touching any flag: write a comment or README note saying how fresh that data needs to be and why. That forces the design decision. Then the flag is a consequence, not magic.
I'm not going to promise you this will reduce your TTFB by some specific percentage or scale to X users. That depends on too many variables only you can measure in your own deploy. What I can tell you is that when the freshness contract is explicit, cache problems become diagnostics, not mysteries.
The concrete next step: take the three most critical fetches in your App Router app and write the freshness contract next to each one. If you can't write it, that's the real friction to resolve before you pick a flag.
Original sources:
- Next.js caching docs: https://nextjs.org/docs/app/building-your-application/caching
Related Articles
Prisma query logging and PostgreSQL: when the ORM is enough and when you have to go deeper
Prisma Client logs show you what queries the ORM generated. PostgreSQL has its own observability layer. Confusing the two is the source of incomplete diagnostics. Here I separate the two planes and give you a concrete criterion for deciding which one to look at first.
MCP Model Context Protocol in TypeScript: build portable tools across Claude, GPT, and local models
The most common mistake when implementing MCP tools is coupling them to the provider's SDK. The spec exists to prevent exactly that. A practical architecture guide: the input/output contract that makes a tool work across Claude, GPT, and local models without rewriting anything.
Web Crypto API in the browser vs Node.js: the differences that will burn you
Web Crypto API looks like one thing — until you try to reuse the same encryption code across browser, Node.js, and Next.js edge runtime. The differences are subtle, they're documented, and almost nobody reads the docs until something blows up.
Comments (0)
What do you think of this?
Drop your comment in 10 seconds.
We only use your login to show your name and avatar. No spam.