Incremental Static Regeneration gets pitched as a free lunch: the performance of static generation, the freshness of server rendering, none of the trade-offs. That framing survives marketing copy but not production traffic. ISR is a caching strategy with specific mechanics — a stale-while-revalidate model bolted onto the build output — and like every caching strategy, it has a shape. Data that fits that shape benefits enormously. Data that doesn't fit it will misbehave in ways that are hard to debug precisely because the page still looks like it's working.
What ISR Actually Does at the Cache Layer
When a route uses ISR, Next.js doesn't re-render on every request the way SSR does, and it doesn't lock the output at build time the way pure SSG does. Instead, it serves the last generated version of the page from cache and, once that version passes its configured lifetime, regenerates it in the background on the next request rather than blocking the response. The visitor who triggers the regeneration still gets the stale page; the next visitor gets the fresh one. According to the Next.js documentation on revalidation, this stale-while-revalidate pattern is the core mechanism behind both time-based and on-demand ISR.
In the Pages Router, this is controlled directly through getStaticProps, returning a revalidate value in seconds:
// pages/blog/[slug].js
export async function getStaticProps({ params }) {
const post = await fetchPost(params.slug)
return {
props: { post },
revalidate: 600, // regenerate at most once every 10 minutes
}
}
In the App Router, the mechanism moved into the data-fetching layer itself. A fetch call can carry a next: { revalidate: <seconds> } option:
// app/blog/[slug]/page.js
export default async function Page({ params }) {
const res = await fetch(`https://api.example.com/posts/${params.slug}`, {
next: { revalidate: 600, tags: ['posts', `post-${params.slug}`] },
})
const post = await res.json()
return <Post data={post} />
}
Or a route segment can export a revalidate config that applies to everything under it:
// app/blog/[slug]/page.js export const revalidate = 600
As the official ISR with Cache Components guide explains, more recent App Router versions layer on cacheLife profiles and the use cache directive, letting a single function or component declare its own cache duration rather than pinning the whole route to one interval:
import { cacheLife } from 'next/cache'
async function getProducts() {
'use cache'
cacheLife('hours')
return db.query('SELECT * FROM products')
}
That's a meaningful shift: instead of one revalidation timer per route, several data dependencies inside the same page can age out independently.
App Router vs. Pages Router: Same Idea, Different Machinery
Pages Router vs. App Router ISR: Pages Router ISR is page-scoped and configured through getStaticProps's revalidate return value, with on-demand updates triggered via res.revalidate() from an API route. App Router ISR is data-scoped, configured through fetch cache options, segment-level revalidate exports, or cacheLife, with on-demand updates triggered via revalidateTag, revalidatePath, or updateTag from a Server Action or Route Handler.
The practical consequence: in the Pages Router, revalidating means regenerating the whole page. In the App Router, a single tagged data dependency can be revalidated without touching the rest of the render tree, provided the fetch was tagged correctly at the outset. That's more granular, but it also shifts the failure mode — an untagged fetch simply doesn't participate in on-demand revalidation, and there's no error telling you that.
On-Demand Revalidation Is Not One Function, It's Three
This is the part most ISR write-ups gloss over: revalidatePath, revalidateTag, and updateTag are not interchangeable, and — per the Next.js guide on how revalidation works — they don't even behave consistently with themselves depending on where they're called from.
revalidateTag, when called with a profile argument such as "max", marks tagged content as stale rather than purging it immediately — the API reference for revalidateTag confirms the stale version keeps serving until the next visit triggers a background refresh, the same SWR behavior as timed revalidation. Called from a CMS webhook handler, it looks like this:
// app/api/webhook/route.js
import { revalidateTag } from 'next/cache'
export async function POST(request) {
const { tag } = await request.json()
revalidateTag(tag, 'max')
return Response.json({ revalidated: true })
}
revalidatePath behaves differently depending on caller. From a Route Handler, the API reference for revalidatePath documents stale-while-revalidate semantics:
// app/api/revalidate/route.js
import { revalidatePath } from 'next/cache'
export async function POST(request) {
const { path } = await request.json()
revalidatePath(path)
return Response.json({ revalidated: true })
}
From a Server Action, it instead uses a read-your-own-writes path that purges and updates the cache immediately — because a Server Action is usually firing right after a mutation the same user needs to see reflected instantly:
'use server'
import { revalidatePath } from 'next/cache'
export async function updatePost(id, data) {
await db.post.update({ where: { id }, data })
revalidatePath(`/blog/${data.slug}`)
}
The same reference notes that revalidatePath operates on the route's file structure, not the URL a visitor sees: a rewritten path needs the destination path passed to revalidatePath, not the source, or the call silently targets the wrong cache entry.
revalidateTag also carries a hard constraint worth knowing before building a tagging scheme around it: tag strings are capped at 256 characters and are case-sensitive, which matters if tags are generated programmatically from slugs or IDs.
Where the Model Breaks: Runtime and Hosting Constraints
ISR's background-regeneration step depends on a serverless or edge function being available to do the regenerating. That's why it works by default on Vercel and works when self-hosting with next start, but doesn't work at all on platforms that only serve static files with no compute layer — GitHub Pages being the clearest example. If the deployment target is a plain CDN with no function runtime attached, ISR isn't a slower version of what you want; it's not available at all.
Self-hosting introduces a second, quieter failure mode. On a single server, ISR's cache lives in memory by default, which is fine. Scale that same app across multiple pods — a Kubernetes deployment, for instance — and each pod holds its own independent copy of the regenerated cache. A request that lands on the pod that already regenerated the page gets fresh content; a request that lands on a pod that hasn't been hit yet still serves the old version. The Next.js rendering documentation addresses this by letting you disable in-memory caching (setting isrMemoryCacheSize to 0 in next.config.js) and point every pod at a shared file-system mount instead — but that's an infrastructure decision made deliberately, not a default, and most teams don't discover the inconsistency until two users see two different versions of the same page at the same time.
There's also a runtime-level restriction worth flagging directly from the docs: the edge runtime does not support ISR. You can approximate stale-while-revalidate behavior on the edge by setting Cache-Control headers manually, but that's a different mechanism from Next.js-managed ISR, with different failure characteristics and no automatic background regeneration.
When ISR Is Structurally the Wrong Choice
Two categories of data defeat ISR regardless of how you tune the interval.
The first is genuinely real-time data — stock prices, live scores, inventory counts that need to be accurate to the second. ISR's freshness ceiling is bounded by whichever revalidation event fires next, whether that's a timer or a webhook call, and there's always a window between the underlying data changing and the cache catching up. This is what that mistake looks like in code — a stock ticker page built with ISR:
// app/stocks/[symbol]/page.js
// WRONG: cached, so every visitor sees a price that's up to 30s stale
export const revalidate = 30
export default async function StockPage({ params }) {
const quote = await fetch(`https://api.example.com/quote/${params.symbol}`)
const data = await quote.json()
return <StockTicker price={data.price} />
}
No matter how low you push the revalidate value, every visitor between regenerations sees the same stale cached price — the page doesn't know or care that the underlying number changed. For that category, server-side rendering on every request, or client-side fetching against a live API, is the correct tool:
// app/stocks/[symbol]/page.js
// RIGHT: forces a fresh render on every request, no cached copy served
export const dynamic = 'force-dynamic'
export default async function StockPage({ params }) {
const quote = await fetch(`https://api.example.com/quote/${params.symbol}`, {
cache: 'no-store',
})
const data = await quote.json()
return <StockTicker price={data.price} />
}
The second is per-user personalized content. ISR caches one version of a route and serves it to everyone until the next regeneration:
// app/dashboard/page.js
// WRONG: this "personalized" page is actually cached and shared
export const revalidate = 3600
export default async function Dashboard() {
const session = await getSession() // resolved once, then baked into the cache
const orders = await fetchOrders(session.userId)
return <OrderList orders={orders} />
}
The first user to hit this route after a regeneration gets their own dashboard cached — and every other visitor gets served that user's data until the cache expires again. A page that renders differently based on session, authentication state, or user-specific data isn't a caching problem ISR is built to solve; it needs to opt out of the static cache entirely and read the session per request:
// app/dashboard/page.js
// RIGHT: reads cookies/session per request, so Next.js renders dynamically
import { cookies } from 'next/headers'
export default async function Dashboard() {
const session = await getSession(cookies())
const orders = await fetchOrders(session.userId)
return <OrderList orders={orders} />
}
Calling cookies() inside the component is what tells Next.js this route depends on per-request data — it opts the route out of static caching automatically, without needing an explicit force-dynamic flag.
When ISR Is Structurally the Right Choice
The pattern where ISR earns its complexity: content that changes on a human cadence rather than a request cadence, and where the writer of that content — not the visitor — is the one who knows when it changed. Blog posts, marketing pages, product catalog pages tied to a CMS, documentation, and generative content produced by discrete events like a git sync or a scheduled API pull all fit this shape. According to Vercel's ISR documentation, media and publishing sites and generative platforms driven by discrete events are among the clearest real-world fits for this model. A CMS-backed blog post is the canonical case — the same shape as the stock ticker above, but here the caching assumption actually holds, because one cached version is correct for every visitor between edits:
// app/blog/[slug]/page.js
// RIGHT: content only changes when an editor publishes — a cached
// version is correct for every visitor until the next edit
export default async function BlogPost({ params }) {
const post = await fetch(`https://cms.example.com/posts/${params.slug}`, {
next: { revalidate: 3600, tags: [`post-${params.slug}`] },
})
const data = await post.json()
return <Article post={data} />
}
In every one of these cases, the ideal revalidation trigger isn't a timer at all — it's on-demand revalidation wired to the actual publish event, via a CMS webhook calling revalidateTag or revalidatePath the moment an editor hits publish, as shown earlier. Time-based revalidation is the fallback for when that webhook doesn't exist yet, not the preferred mechanism.
Key Takeaways
- ISR is a per-route (or, in the App Router, per-fetch) stale-while-revalidate cache, not a blanket performance mode — it has to be applied deliberately to content that fits its assumptions.
- The App Router's tag- and path-based revalidation is more granular than the Pages Router's whole-page
revalidate(), but only if fetches are tagged correctly from the start. revalidatePathbehaves differently depending on whether it's called from a Route Handler (stale-while-revalidate) or a Server Action (read-your-own-writes) — a common source of "why didn't this update immediately" bugs.- ISR requires a serverless or edge compute layer at deploy time; it does not run on pure static hosts, and it does not run on the standard edge runtime.
- Self-hosted, multi-pod deployments need a shared cache mount or they'll serve inconsistent versions of the same page across pods.
- Real-time data and per-user personalized content are structurally incompatible with ISR — no interval tuning fixes that, because the problem isn't staleness duration, it's that ISR assumes one shared cached version per route.
The decision isn't "ISR or not" for the whole app — it's evaluating each route against two questions: does this content change on a schedule the publisher controls, and is one cached version correct for every visitor. Where both answers are yes, ISR is close to free performance. Where either answer is no, the fix isn't a shorter revalidate window — it's a different rendering strategy for that specific route.





