Next.js App Router Caching: revalidate, dynamic, and no-store Without the Folklore
What the Official Docs Say - and What They Don't
The Next.js caching documentation describes four cache layers that interact with each other:
- Request Memoization - deduplication of
fetchcalls with the same URL within a single render. - Data Cache - persistence of fetch responses across requests (configurable with
revalidateorno-store). - Full Route Cache - static HTML + RSC payload generated at build time or runtime.
- Router Cache - client-side cache for prefetching segments.
What the docs explain well: how to configure each layer, what options exist, what semantics they carry. What the docs don't say: which one to pick based on the type of data you're serving. That's a product decision, not an API decision. And that's exactly where most people get lost. The docs are honest about the system's limits, but not prescriptive about when each option actually makes sense. That part is on you.
Read the Flags as Data Contracts, Not Tricks
Before writing a single line of configuration, the right question isn't "which flag do I use?" It's: How old can this data get before the user notices something is wrong? That's the data contract. And every caching option in Next.js is a way to declare that contract explicitly.
revalidate: N (ISR - time-based revalidation)
// Data that changes, but not every second
// e.g.: blog posts, catalog prices with low turnover
export const revalidate = 3600 // revalidate every hour
Contract: "This data can be up to N seconds old. I'm okay with that."
When it makes sense: editorial content, product catalogs that don't change hourly, landing pages with semi-structured data.
When it doesn't make sense: real-time stock, user-personalized data, operational dashboards.
dynamic = 'force-dynamic'
// Every request triggers a fresh server render
// Equivalent to getServerSideProps in Pages Router
export const dynamic = 'force-dynamic'
Contract: "This data can't tolerate any staleness. I want it fresh on every request."
The uncomfortable truth about this flag: it's the easiest to slap on and the most expensive to sustain. No Full Route Cache, no Data Cache for this segment. Every single user pays the full cost of a fresh render. That can be exactly the right call - but it has to be a decision, not the default when something isn't working.
cache: 'no-store' on fetch
// At the individual fetch level - more granular than dynamic
const res = await fetch('https://api.example.com/sensitive-data', {
cache: 'no-store' // never cache this response
})
Contract: "This specific piece of data should never be cached, even if other data in the same component can be."
The difference from force-dynamic is scope. no-store is surgical: it affects one specific fetch. force-dynamic disables the cache for the entire segment. If you need one fresh piece of data inside a page that otherwise has cacheable content, no-store on the fetch is the right tool.
No configuration (default)
// No revalidate, no dynamic, no cache: 'no-store'
// Next.js caches indefinitely at build time (or until manual invalidation)
const res = await fetch('https://api.example.com/static-content')
Contract: "This data is static. Generate once, serve forever, invalidate when the code changes."
Ideal for content that doesn't change between deploys: legal pages, documentation, institutional landing pages.
Where People Go Wrong (and the Hidden Cost)
The most common mistake I see in App Router projects isn't picking the wrong flag. It's not choosing consciously and letting the default decide. Three problematic patterns:
force-dynamicas a universal escape hatch - When something "isn't updating properly," the first instinct is to throwforce-dynamicat it. It works. But if the data could tolerate 5 minutes of staleness, you're paying the full render-per-request cost for no reason. There's no magic number here - it depends on your traffic volume and infrastructure - but the cost is real and worth measuring.Mixing
revalidatein layout and page without understanding inheritance - The docs are clear: the most restrictive segment wins. If the layout hasrevalidate = 0(equivalent toforce-dynamic) and the page hasrevalidate = 3600, the segment will render dynamically regardless. This catches a lot of people off guard because the page config "looks like it's being ignored."Assuming
revalidateis exact - ISR withrevalidate: 60does not guarantee your data updates exactly at 60 seconds. Revalidation is stale-while-revalidate: the first request after expiration returns stale data and triggers a background regeneration. The next request - which could be milliseconds or minutes later depending on traffic - gets the fresh data. If your system needs strict time-based consistency, ISR is not the right tool.
Decision Matrix: Which Flag Does Each Data Need
Before touching any configuration, answer these questions:
| Question | Answer โ option |
|---|---|
| Does the data change between deploys? | No โ default (static) |
| Can it tolerate N seconds/minutes of staleness? | Yes โ revalidate: N |
| Does it need to be fresh on each request, but only this one fetch? | Yes โ cache: 'no-store' on the fetch |
| Does the entire segment need to be fresh on every request? | Yes โ dynamic = 'force-dynamic' |
| Does the data depend on the authenticated user, headers, or cookies? | Yes โ force-dynamic or cookies() / headers() (they activate dynamic automatically) |
A note on that last row: in App Router, calling cookies() or headers() inside a Server Component automatically activates dynamic rendering for that segment, even if you never declare force-dynamic. This is documented behavior, but it surprises people constantly.
Reference Snippet: ISR with On-Demand Revalidation
This pattern combines time-based revalidate with on-demand revalidation via a Route Handler. It's useful when content changes unpredictably but you can fire an invalidation from a webhook or CMS:
// app/blog/[slug]/page.tsx
export const revalidate = 3600 // fallback: revalidate every hour
// app/api/revalidate/route.ts
import { revalidatePath } from 'next/cache'
import { NextRequest } from 'next/server'
export async function POST(request: NextRequest) {
const { path } = await request.json()
// Validate that the request comes from a trusted source
// (e.g.: secret token from the CMS)
revalidatePath(path)
return Response.json({ revalidated: true })
}
This isn't a production-ready recipe - it's a starting point. Webhook authentication, error handling, and path strategy are decisions that depend on your specific system.
Honest Limits: What You Can't Conclude Without Real Data
Before wrapping up, I want to be clear about what this guide does not prove:
- There are no performance benchmarks here. The impact depends on request volume, deploy infrastructure (Vercel, Railway, self-hosted), and the response time of your external data sources. Without measurement in a real context, any number is folklore.
- The Router Cache (client) interacts with the Full Route Cache (server) in ways that can genuinely surprise you. The official docs describe the cases, but the concrete behavior in an app with complex navigation is worth validating with Next.js DevTools or server logs.
- ISR in self-hosted environments doesn't behave exactly the same as on Vercel. Some on-demand revalidation features assume specific infrastructure. Read the Next.js deploy docs for your platform before assuming behavioral parity.
If you're making a caching architecture decision for a critical system, these limits matter. Measure before you assume.
FAQ: Next.js App Router Caching
What's the difference between cache: 'no-store' and dynamic = 'force-dynamic'?
Scope. cache: 'no-store' affects a single fetch: that data doesn't cache, but the rest of the segment can still cache normally. force-dynamic disables the Full Route Cache for the entire segment: every request triggers a fresh server render, regardless of how individual fetches are configured. Use no-store when you need surgical precision; force-dynamic when the entire segment depends on fresh data or request context (user, cookies, headers).
Is revalidate: 0 the same as force-dynamic?
In practice, yes. The Next.js docs indicate that revalidate: 0 opts into dynamic rendering. But for clarity of intent, force-dynamic is more explicit and less ambiguous for whoever reads the code later.
What happens if I have different revalidate values in the layout and the page?
The most restrictive value wins (the lowest one). If the layout has revalidate = 60 and the page has revalidate = 3600, the segment revalidates every 60 seconds. This can make the page config look ignored - but it's documented behavior. Always check the configuration of layouts wrapping your pages.
How do I know if a Server Component is rendering statically or dynamically?
Run next build and look at the terminal output: static routes are marked (โ), dynamic (ฮป), and ISR (โ). You can also use the Next.js dev toolbar in local development, which shows the render mode for each segment.
Does the client Router Cache interfere with my server-side configurations?
Yes, and it's a common source of confusion. The Router Cache stores visited segments on the client for a configurable duration - separate from the server cache. You can control this with staleTimes in your Next.js config (available since Next.js 14.2). If data looks stale on back navigation even though the server is revalidating correctly, the Router Cache is your first suspect.
Does ISR work the same in all deploy environments?
Not exactly. On Vercel, ISR has native CDN-level support. In self-hosted environments (Railway, VPS, Docker), revalidation works but depends on the Node.js server implementation and doesn't necessarily distribute the cache across instances. The Next.js docs have a specific section on self-hosting that's worth reading before assuming behavioral parity.
Closing: The Decision Comes Before the Flag
Next.js App Router caching isn't complicated when you read it as a system of contracts. Each option declares something about the staleness the data can tolerate. The work isn't memorizing which flag does what - the docs solve that in two minutes. The work is knowing, before you write a single line, how old each piece of information you're serving is allowed to get.
If you have data that changes every 5 minutes and you're serving it with the static default, you're breaking the contract without realizing it. If you have editorial content that could cache for hours and you're using force-dynamic, you're paying a cost nobody consciously approved.
My practical recommendation: before touching the configuration of any page or layout, write a comment answering "how old can this data get?" If you can't answer that question, the technical conversation hasn't started yet.
The concrete next step: open the next build output and look at which routes are static, which are dynamic, and which are ISR. If there are surprises, that's where the real investigation begins.
Original sources: Next.js caching docs - App Router
If you're into the idea of implicit contracts in infrastructure, I have related posts on Docker healthchecks and what they actually measure and digital signatures: the difference between format, certificate, and validation policy that follow the same logic.
This article was originally published on juanchi.dev
Comments
No comments yet. Start the discussion.