DEV Community

Cover image for Next.js App Router caching: revalidate, dynamic, and no-store without the folklore
Juan Torchia
Juan Torchia Subscriber

Posted on • Originally published at juanchi.dev

Next.js App Router caching: revalidate, dynamic, and no-store without the folklore

Next.js App Router caching: revalidate, dynamic, and no-store without the folklore

I made the classic mistake: I slapped export const dynamic = 'force-dynamic' on a route that took 800ms to respond and felt satisfied because "at least the data was fresh." I measured nothing. I didn't understand which piece of data actually needed that freshness. I just applied the flag that fixed the visible symptom — stale data — without asking myself whether the cost was worth it. Months later, reviewing the architecture, I realized 70% of those routes were serving data that changed once an hour. I was regenerating them on every single request for no valid technical reason.

I'm not telling you this to beat myself up. I'm telling you because that mistake is almost universal in teams learning App Router.

My thesis: the problem isn't memorizing cache options. It's deciding what freshness each piece of data needs before you touch any configuration at all. The flags are a consequence of that decision — not the starting point.


What the official docs say — and what they don't

The Next.js caching documentation describes four layers: Request Memoization, Data Cache, Full Route Cache, and Router Cache. It's a solid technical reference. What it doesn't do — and it's not supposed to — is tell you which data deserves which layer.

The docs explain the mechanism. The design decision is yours.

A few points the docs make clear that are worth reinforcing:

  • fetch with cache enabled by default (before Next.js 15) stored responses in the Data Cache indefinitely unless you said otherwise. In Next.js 15 this changed: the default behavior for fetch in Route Handlers and Server Components switched to no-store. Don't assume the default without checking the version.
  • revalidate applies a time-to-live to the data in the Data Cache and to the segment in the Full Route Cache. When the time expires, the next request regenerates in the background (ISR) and the user gets the previous version in the meantime.
  • dynamic = 'force-dynamic' opts the entire segment out of the Full Route Cache. It's equivalent to cache: 'no-store' on every fetch in the segment, plus a signal that the route cannot be pre-rendered.
  • no-store on an individual fetch excludes that data from the Data Cache. You don't need to force the whole route dynamic if only one fetch needs fresh data.

The distinction between "exclude one fetch" and "exclude the whole route" is exactly where the logic breaks down for people who learn the flags by rote.


Reading cache as a data contract

Every cache option is an implicit promise about the freshness of the data you're serving. If you think about it that way, the decision gets a lot clearer:

Option Promise to the user Operational cost
cache: 'force-cache' (pre-15 default) "This data can be any age until you manually revalidate" Minimum — served from cache
revalidate: N "This data is at most N seconds old" Background rebuild every N seconds, one request pays the regeneration cost
cache: 'no-store' "This data is always as fresh as possible" External fetch on every request
dynamic = 'force-dynamic' "This entire route can't be pre-rendered; everything goes to origin" No segment in Full Route Cache

Before writing any flag, the useful question is: how many seconds of staleness in this data actually changes the user experience or the correctness of the system?

For a personal blog, 3600 seconds is perfectly fine. For a product price, maybe 60 seconds is reasonable depending on the use case. For a user's shopping cart, no-store is the right answer — not because it's the "safe" flag, but because that data has to be exact at render time.

// Right: each fetch with its own contract
async function BlogPost({ slug }: { slug: string }) {
  // Post content changes rarely — revalidate every hour
  const post = await fetch(`/api/posts/${slug}`, {
    next: { revalidate: 3600 }
  })

  // Comments change more often — every 5 minutes
  const comments = await fetch(`/api/posts/${slug}/comments`, {
    next: { revalidate: 300 }
  })

  // User session state never goes to cache
  const session = await fetch('/api/session', {
    cache: 'no-store'
  })

  // ...
}
Enter fullscreen mode Exit fullscreen mode

This is what the docs make possible but don't prescribe: per-data granularity, not per-route granularity.


Where people go wrong — and the cost they don't see

Mistake 1: force-dynamic as the default solution

When something "doesn't work" with cache, the instinct is to turn the whole thing off. The problem is that force-dynamic on a high-traffic public route means every single request goes to origin — with zero benefit from the Full Route Cache. On Vercel and equivalent platforms, that translates directly into function execution time on every visit. It's not free.

Mistake 2: revalidate: 0 as "the same as no-store"

They're not equivalent. revalidate: 0 has unspecified behavior in older versions of the framework. If you want fresh data on every request, use cache: 'no-store' explicitly. Intent matters for the next person reading the code.

Mistake 3: mixing segment-level revalidate and fetch-level revalidate without understanding precedence

If a segment has export const revalidate = 60 and a fetch inside it has next: { revalidate: 3600 }, the effective revalidation time for that fetch is capped by the lower of the two values. The docs cover this, but it's easy to miss when you set the segment globally and add individual fetches later.

// file: app/dashboard/page.tsx

// This segment revalidate acts as a ceiling for all fetches
export const revalidate = 60

async function DashboardPage() {
  // Even though you're asking for 3600, the segment caps it at 60 seconds
  const data = await fetch('/api/dashboard', {
    next: { revalidate: 3600 } // effective: 60 due to segment
  })
  // ...
}
Enter fullscreen mode Exit fullscreen mode

Mistake 4: not considering revalidatePath and revalidateTag as an alternative

For data that changes by event — a post that gets published, a price that gets updated — time-based ISR is a suboptimal mechanism. revalidateTag in a Server Action or Route Handler lets you invalidate the cache exactly when the data changes, without waiting for a timeout. The docs cover this in detail. It's the right option when the domain has clear mutation events — something that also connects to the patterns I went through in the post on Prisma Server Actions in Next.js.


Decision matrix: what to ask about each piece of data

Before configuring cache on any segment or fetch, run through these questions:

1. Is this data user-specific?
→ Yes: no-store, or cookies/headers that already opt out of the Full Route Cache automatically.
→ No: keep going.

2. When does this data change?
→ On a known event (publish, update): revalidateTag in the mutation + fetch with a tag.
→ On a schedule: revalidate: N with an N that makes sense for the domain.
→ Never (or rarely): explicit force-cache or ISR with a high revalidate.

3. What happens if the user sees data that's 60 seconds old?
→ Nothing critical: ISR with revalidate: 60 is perfectly valid.
→ Something incorrect or confusing: no-store.

4. Is this a high-traffic public route?
→ Yes: the Full Route Cache is valuable. Avoid force-dynamic unless it's strictly necessary.
→ No (authenticated dashboard, for example): the penalty for force-dynamic is smaller.

// Pattern with revalidateTag — useful when data changes by event
// app/blog/[slug]/page.tsx
async function BlogPostPage({ params }: { params: { slug: string } }) {
  const post = await fetch(`/api/posts/${params.slug}`, {
    next: {
      tags: [`post-${params.slug}`] // tag for explicit invalidation
    }
  })
  // ...
}

// app/actions/publish-post.ts (Server Action)
'use server'
import { revalidateTag } from 'next/cache'

export async function publishPost(slug: string) {
  // Publish the post to the database...
  revalidateTag(`post-${slug}`) // invalidates exactly that piece of data
}
Enter fullscreen mode Exit fullscreen mode

Honest limits: what you can't conclude without your own data

The official docs describe framework behavior. They don't prescribe performance metrics, platform costs, or revalidate thresholds for specific use cases.

Some things you can't decide from the documentation alone:

  • The "right" revalidate time for your domain. That depends on the actual rate of change of your data — something only your own production logs can tell you.
  • Whether time-based or event-based ISR is more efficient in your case. It depends on mutation volume vs. read traffic.
  • Whether the cost of force-dynamic on a public route is significant. It depends on the deploy platform, the traffic, and the function execution time. Vercel has its own cost model; Railway has another.

What you can do before you have production data: define the freshness contract per data point during design, then adjust the numeric value of revalidate when you have real information. Starting with a reasonable number and changing it is much cheaper than starting with global force-dynamic and never revisiting it.

If you're working with monorepos and CI, the cache conversation extends well beyond runtime — something I explored from a different angle in the post on pnpm workspaces and CI cache. And if you're thinking about how to protect the dynamic routes that genuinely need no-store, the rate limiting per route model is the logical next step.


FAQ

Are dynamic = 'force-dynamic' and cache: 'no-store' the same thing?

Not exactly. cache: 'no-store' on a fetch excludes that data from the Data Cache. force-dynamic on a segment excludes the entire route from the Full Route Cache and signals that it can't be pre-rendered. The first is per-fetch granular; the second is a whole-segment decision. You can have fetches with no-store inside a route that's still in the Full Route Cache, as long as those fetches don't affect the full render.

Did the default cache behavior change in Next.js 15?

Yes. In Next.js 15, the default behavior of fetch in Route Handlers and Server Components switched to no-store (no cache), reversing the aggressive default from earlier versions. If you're migrating or reviewing Next.js 13/14 code, this change can explain different behaviors. The official docs cover the defaults per version.

When does it make sense to use revalidateTag instead of revalidate: N?

When the data has well-defined mutation events. If you publish an article, update a price, or change configuration, revalidateTag invalidates exactly that data at that exact moment. revalidate: N is useful when you don't control when the external data changes — a third-party API, for example — and you need a "guaranteed expiration" mechanism.

What happens if I mix segment-level revalidate with fetch-level revalidate?

The segment acts as a ceiling. If the segment has revalidate: 60 and a fetch has revalidate: 3600, the data revalidates every 60 seconds, not every hour. The lower value between segment and fetch wins. This is documented in the official reference.

Does no-store guarantee the data is never served from cache at any layer?

It excludes the data from Next.js's Data Cache. It has no control over intermediate caches — CDN, proxy, HTTP headers from the external origin. If the external fetch returns Cache-Control: max-age=300, that data can sit in a layer that's outside Next.js's hands. For absolute freshness guarantees, the origin has to cooperate too.

Does it make sense to use explicit force-cache in Next.js 15 if the default changed?

Yes, and it's good practice for readability. If the data contract is "this can be cached indefinitely until manual invalidation," declaring it explicitly with force-cache makes the intent visible to whoever reads the code next. Don't rely on default behavior to communicate design decisions.


Closing: the decision comes before the flag

There's no single correct cache configuration for App Router. There are configurations that match — or don't match — the freshness contract each piece of data needs.

My practical recommendation: before you write dynamic, revalidate, or no-store, write a comment that answers "how many seconds of staleness in this data is acceptable, and why?" If you can't answer that, you don't have enough information to pick the flag — and whatever you put there is just folklore.

The concrete next step: open the official App Router caching documentation, find the section for your version of Next.js, and verify the default fetch behavior for that version. It's the quietest breaking change between Next.js 14 and 15, and the easiest one to miss.


Primary source:


This article was originally published on juanchi.dev

Top comments (0)