
Incremental Static Regeneration (ISR)
Static generation and server-side rendering used to be a hard fork in the road. You either prerendered a page at build time and served it instantly forever, or you rendered it fresh on every request and paid a latency cost for the privilege of always being correct. Incremental Static Regeneration exists because most real applications don't want either extreme — they want the speed of a static page with content that doesn't go stale for months at a time.
If you're building anything with a content backend — a blog, a product catalog, a docs site, a marketplace listing page — you'll run into this exact tension almost immediately. ISR is Next.js's answer to it, and it's one of those features that looks simple on the surface (just add one line of config) but has enough sharp edges underneath that it's worth understanding properly before you rely on it in production.
This article covers ISR as it works without the newer Cache Components model. If your project has cacheComponents enabled in next.config.js, the mental model shifts and you'll want ISR with Cache Components instead — the revalidate export described here doesn't apply in that mode. Everything below assumes a standard Next.js 16 App Router project on the classic caching model, which is still the default and still what the vast majority of production apps run on today.
What ISR Actually Buys You
Strip away the marketing language and ISR gives you four concrete things:
- You can update the content behind a static page without triggering a full site rebuild.
- Most visitors get served an already-rendered HTML file straight from cache — no render cost, no database round trip, no waiting.
- Next.js automatically attaches the right
cache-controlheaders so CDNs and browsers know how to treat the response. - You can have thousands of dynamic-looking pages (blog posts, product pages) without
next buildtaking twenty minutes, because you don't have to prerender all of them upfront.
The trick that makes this possible is a Next.js-flavored version of the stale-while-revalidate pattern that's been in HTTP caching for years. A page gets prerendered once. For a window of time you define, every request gets served that same cached page — no exceptions, no matter how old the underlying data actually is. Once that window expires, the next request still gets the stale cached page immediately (so nobody ever waits on a slow regeneration), but Next.js quietly kicks off a background job to regenerate the page with fresh data. Once that finishes, the cache is swapped, and everyone after that gets the new version — until it goes stale again and the cycle repeats.
Here's the minimal shape of it:
// app/blog/[id]/page.tsx
interface Post {
id: string;
title: string;
content: string;
}
// Next.js will invalidate the cache when a
// request comes in, at most once every 60 seconds.
export const revalidate = 60;
export async function generateStaticParams() {
const posts: Post[] = await fetch("https://api.vercel.app/blog").then((res) =>
res.json(),
);
return posts.map((post) => ({
id: String(post.id),
}));
}
export default async function Page({
params,
}: {
params: Promise<{ id: string }>;
}) {
const { id } = await params;
const post: Post = await fetch(`https://api.vercel.app/blog/${id}`).then(
(res) => res.json(),
);
return (
<main>
<h1>{post.title}</h1>
<p>{post.content}</p>
</main>
);
}
Two exports are doing all the work here. generateStaticParams tells Next.js which dynamic segment values exist so it can prerender a page per post during next build. revalidate is the number that turns a plain static page into an ISR page — set it to a number of seconds, and Next.js will treat any cached response for that route as fair game for background regeneration once that time has elapsed.
Walking through what happens after deploy:
- At build time, Next.js prerenders one HTML page per post returned by
generateStaticParams. - Every request to
/blog/1is served that cached page instantly — no server-side work happens at all. - Once 60 seconds have passed since the last generation, the next request still gets the old (now stale) page immediately.
- In the background, Next.js regenerates that page with fresh data.
- Once regeneration succeeds, the cache is updated. Every request after that gets the new version, cached again for another 60 seconds.
- If someone requests a post that didn't exist at build time — say
/blog/26, published after your last deploy — Next.js will generate it on demand the first time it's requested, then cache it going forward. If the post genuinely doesn't exist, you get a normal 404.
That last point trips people up constantly, so it's worth sitting with: ISR isn't only for content that changes, it's also how you deal with content that didn't exist yet when you built. You don't need to redeploy every time an editor publishes a new blog post — the first visitor to the new URL pays a one-time render cost, and everyone after them gets the cached page.
Time-Based Revalidation in Practice
The example above revalidates a single dynamic page, but the same revalidate export works on any route, including list pages that aggregate data:
// app/blog/page.tsx
interface Post {
id: string;
title: string;
content: string;
}
export const revalidate = 3600; // invalidate every hour
export default async function Page() {
const data = await fetch("https://api.vercel.app/blog");
const posts: Post[] = await data.json();
return (
<main>
<h1>Blog Posts</h1>
<ul>
{posts.map((post) => (
<li key={post.id}>{post.title}</li>
))}
</ul>
</main>
);
}
The number you pick here matters more than it seems like it should. The instinct when you're new to ISR is to set revalidate aggressively low — 5 seconds, 10 seconds — because "fresh data" feels safer. In practice this is almost always the wrong move. Every revalidation triggers a real render on your server (or serverless function), and if you're on a platform that bills per invocation or per compute-second, an aggressively short revalidate window on a high-traffic page turns into a real, recurring cost for marginal freshness gains that your users will never notice. A visitor looking at a blog post doesn't care whether the page was generated 8 seconds ago or 45 minutes ago.
The official guidance is to default to something like an hour rather than a few seconds, and reach for on-demand revalidation (below) when you actually need precision — like the instant after an editor hits publish. Time-based revalidation is for "this will drift eventually and that's fine," not for "this needs to be correct within a few seconds of a change happening."
On-Demand Revalidation: revalidatePath
Time-based revalidation is a blunt instrument — it doesn't know anything happened, it just periodically checks in. If you know exactly when your data changed (someone published a post, updated a price, edited a page), you can invalidate the cache the moment it happens instead of waiting for a timer.
revalidatePath is the simplest version of this. You call it from a Server Action (or a Route Handler) right after a mutation, and it tells Next.js "the cached data behind this path is no longer valid."
// app/actions.ts
"use server";
import { revalidatePath } from "next/cache";
export async function createPost() {
// ...save the post to your database...
// Invalidate the cache for the /posts route
revalidatePath("/posts");
}
There's a subtlety here that's easy to miss on a first read: revalidatePath invalidates the cache entry, but it doesn't regenerate the page synchronously. Regeneration happens on the next request to that path, the same as with time-based revalidation — the difference is you've forced the "next request" to trigger a rebuild immediately instead of waiting out a timer. If you need the cache eagerly regenerated the instant you call this (rather than lazily on next visit), that capability currently only exists on the Pages Router via res.revalidate. The App Router doesn't have an equivalent yet, though it's an acknowledged gap the Next.js team has said they're working on.
In practice this rarely matters for user-facing content — the person who just published the post is going to visit the page anyway, which triggers the regeneration, and every subsequent visitor gets the fresh version. It matters more if you have automated systems (a build pipeline, a cache warmer, a sitemap crawler) that assume revalidation is instantaneous and complete by the time the call returns.
On-Demand Revalidation: revalidateTag
revalidatePath invalidates everything rendered under a path, which is often exactly what you want but is also a fairly coarse hammer. revalidateTag gives you finer control by letting you tag individual data fetches and invalidate by tag instead of by route.
First, tag the fetch:
// app/blog/page.tsx
export default async function Page() {
const data = await fetch("https://api.vercel.app/blog", {
next: { tags: ["posts"] },
});
const posts = await data.json();
// ...
}
If you're not using fetch directly — say you're going through an ORM or a raw database client — you tag the cache entry through unstable_cache instead:
// app/blog/page.tsx
import { unstable_cache } from "next/cache";
import { db, posts } from "@/lib/db";
const getCachedPosts = unstable_cache(
async () => {
return await db.select().from(posts);
},
["posts"],
{ revalidate: 3600, tags: ["posts"] },
);
export default async function Page() {
const cachedPosts = await getCachedPosts();
// ...
}
Then, wherever the mutation happens, invalidate by tag instead of by path:
// app/actions.ts
"use server";
import { revalidateTag } from "next/cache";
export async function createPost() {
// Invalidate all data tagged with 'posts'
revalidateTag("posts");
}
The advantage over revalidatePath shows up once your app has more than a handful of routes. If the same "list of recent posts" data feeds a homepage widget, a sidebar, and a dedicated blog index, tagging that one fetch call and calling revalidateTag("posts") invalidates all three surfaces in one shot — you don't have to remember and enumerate every path that happens to render that data. Paths are about where something is rendered; tags are about what data it depends on. Once you start thinking in tags, path-based invalidation starts to feel like it's solving the wrong problem for anything beyond the simplest single-page case.
What Happens When Regeneration Fails
This is the part of ISR's failure model that genuinely surprises people the first time they hit it, and it's also one of ISR's best guarantees: if an error is thrown while Next.js is attempting to regenerate a stale page, the last successfully generated version keeps being served from cache. Nothing breaks for your visitors. Next.js will simply retry the regeneration on the next request.
This means a transient failure in your data source — an API timeout, a database blip, a third-party service having a bad five minutes — doesn't take your page down. Worst case, users keep seeing slightly-more-stale content than intended until the underlying issue clears up and a regeneration attempt succeeds. Compare that to a fully dynamic SSR page with the same upstream dependency: an API failure there means every single request fails until the API recovers. ISR effectively gives you a free degrade-gracefully behavior as a side effect of how the caching layer is designed, and it's worth explicitly building your mental model around that rather than discovering it by accident during an incident.
Debugging ISR Locally and in Production
ISR behavior is invisible in next dev — the dev server doesn't cache the way production does, so if you're trying to verify revalidation timing or cache hits/misses, you have to build and run the production server:
npm run build
npm run start
Once you're running against a production build, two tools help you see what's actually happening.
First, if your data comes through fetch, you can turn on verbose fetch logging to see which requests are cached versus uncached:
// next.config.js
module.exports = {
logging: {
fetches: {
fullUrl: true,
},
},
};
Second, there's an undocumented-feeling but genuinely useful environment variable that makes the server log ISR cache hits and misses directly:
# .env
NEXT_PRIVATE_DEBUG_CACHE=1
With that set, watch your server logs while you hit different routes and you'll see exactly which pages were generated at build time versus on-demand, and when a background regeneration kicks off. This is genuinely the fastest way to build an accurate mental model of ISR's behavior for your specific app, rather than reasoning about it purely from documentation.
There's also a response header worth knowing about for production debugging without server log access: x-nextjs-cache. Its value tells you exactly what happened for that specific request:
| Header value | Meaning |
|---|---|
HIT | Served straight from cache, no regeneration triggered |
STALE | Served from cache, but a background regeneration is now running |
MISS | Not in cache at all — rendered fresh for this request |
REVALIDATED | Regenerated because of an on-demand revalidatePath/revalidateTag call |
If you're ever debugging "why does this page look stale in production," checking this header in your browser's network tab is the fastest first step, faster than reaching for server logs at all.
The Caveats That Actually Bite
The official docs list these as a bullet list of caveats, but a few of them are the kind of thing that will genuinely break your app in production if you don't know about them ahead of time, so they deserve more than a passing mention.
ISR only works on the Node.js runtime. If you've put a route on the Edge runtime for latency reasons, ISR is not available there — you'd need to move that route back to the default Node.js runtime to use it.
ISR and static exports don't mix. If you're using output: 'export' to produce a fully static site with no server at all, ISR simply isn't supported — by definition, there's no server around to run the background regeneration.
Mixed revalidate times on one page collapse to the lowest. If a single route has multiple fetch calls with different revalidate values, the page as a whole gets regenerated on whichever interval is shortest. The individual fetches still respect their own revalidate windows for caching purposes, but the page's overall regeneration cadence is governed by the most demanding fetch on it. This means one overly aggressive revalidate: 10 buried in a shared component can quietly force regeneration far more often than you intended for an entire page.
A revalidate: 0 or no-store fetch anywhere on the route makes the whole route dynamic. If any fetch in a route opts out of caching entirely, Next.js can't treat that route as statically cacheable anymore — the whole thing switches to dynamic rendering. This is a common accidental footgun: someone adds one uncached fetch call to an otherwise fully static, ISR-enabled page (often for something like a live inventory count) and unknowingly turns the entire page dynamic, undoing all the performance benefits ISR was providing.
Proxy (the successor to Middleware) doesn't run for on-demand ISR requests. If your app rewrites paths in proxy.ts — say, mapping /post-1 to an internal /post/1 — calling revalidatePath('/post-1') won't apply your rewrite logic. You need to revalidate the actual underlying path, /post/1, not the rewritten one a visitor sees in their browser.
Multi-instance deployments need a shared cache handler. The default file-system-based cache is per-instance. If you're running more than one server instance behind a load balancer (which is common on many self-hosted setups), calling revalidatePath or revalidateTag only invalidates the cache on whichever instance happened to handle that specific request — the others keep serving stale content until their own timer expires. To get on-demand revalidation working consistently across instances, you need a shared custom cache handler (backed by something like Redis or a database) instead of the default per-instance file system cache.
Background regeneration runs on whichever instance handled the triggering request, and it costs compute. On platforms that bill per request or per compute-second, this is a real, if usually small, line item — a page that revalidates constantly under high traffic is quietly generating regeneration work in the background on top of the requests you're already paying for.
Where Persisted Cache Data Actually Lives
By default, ISR's cache lives on the local file system of whatever server is running your app. That's fine for a single-instance deployment, but it means the cache doesn't automatically survive a redeploy (a fresh container starts with an empty cache) and doesn't automatically share state across multiple instances of your app running in parallel.
If either of those matter to you — and they usually start mattering the moment you scale past one instance, or want warm caches to survive deploys — you configure a custom cache handler that persists to durable storage like Redis, an object store, or a database, instead of the local disk. This is also the setting that determines whether ISR "remembers" anything across a full redeploy versus starting cold every time. It's worth setting up deliberately rather than discovering the hard way that your production cache resets on every deploy.
Platform Support Isn't Universal
Not every hosting target supports ISR the same way, and this is worth checking before you build a whole content strategy around it:
| Deployment option | ISR supported |
|---|---|
| Node.js server | Yes |
| Docker container | Yes |
| Static export | No |
| Platform adapters | Depends on the platform |
If you're self-hosting on a plain Node.js server or a Docker container, ISR just works with the default file-system cache (with the multi-instance caveat above). If you're deploying through a platform-specific adapter, whether ISR — and specifically, whether on-demand revalidation and background regeneration — is fully supported depends on what that adapter implements. It's worth explicitly confirming rather than assuming, since "static hosting with some server-side glue" platforms sometimes support the build-time half of ISR (initial static generation) without fully supporting the runtime half (on-demand revalidation, background regeneration).
Common Mistakes Worth Avoiding
A few patterns show up often enough in real codebases that they're worth calling out explicitly, since none of them produce an error — they just quietly undermine the point of using ISR in the first place.
Setting revalidate far lower than the data actually changes. If your CMS content changes a few times a week, revalidate = 30 doesn't make anything meaningfully fresher for your readers — it just multiplies your regeneration workload for no visible benefit. Match the interval to how often the data realistically changes, then use on-demand revalidation for the rare moment you need it faster than that.
Forgetting that generateStaticParams controls what gets built upfront. If you don't export it at all, Next.js has no list of paths to prerender at build time, and every single page under that dynamic segment gets generated on-demand on its first visit instead. That's not necessarily wrong — for a catalog with tens of thousands of rarely-visited pages, prerendering all of them at build time might be wasteful — but it should be a deliberate choice, not something you discover after your next build output looks suspiciously empty.
Mixing a no-store fetch into an otherwise static page without realizing the consequence. As covered above, this silently converts the whole route to dynamic rendering. If you need one piece of genuinely real-time data (a live view counter, current stock level) on an otherwise mostly-static page, the better pattern is usually to isolate that piece behind a Suspense boundary or fetch it client-side, rather than letting it drag the entire server-rendered page into dynamic mode.
Assuming revalidatePath regenerates synchronously. As covered earlier, it invalidates the cache entry; the actual regeneration happens on the next request to that path. If you have any tooling (a cache warmer, a build step, a test) that calls revalidatePath and immediately checks for fresh content, it needs to make a follow-up request to actually trigger the regeneration — the call to revalidatePath itself won't do it.
Relying on the default cache handler across multiple instances. If you scale horizontally at all, on-demand revalidation silently becoming instance-scoped instead of application-scoped is the kind of bug that's invisible in staging (one instance) and confusing in production (several instances, inconsistent results depending on which one served you). If you're behind a load balancer, treat a shared cache handler as a requirement, not an optimization.
ISR vs. the Alternatives
It helps to see ISR next to the two options it sits between, since the tradeoffs only make sense in contrast:
| Approach | Speed | Freshness | Server load | Best for |
|---|---|---|---|---|
| Full static generation (no revalidate) | Fastest possible | Frozen at build time | None after build | Content that truly never changes post-deploy |
| ISR | Fast (cached) with occasional background work | Eventually consistent, tunable | Low, proportional to traffic and revalidate frequency | Content-heavy pages: blogs, catalogs, docs |
| Fully dynamic rendering (SSR) | Slower, every request computed | Always current | Highest — every request does real work | Personalized or truly real-time data |
Most pages people reach for SSR on don't actually need it — they need ISR with a sensible revalidation window and an on-demand trigger for the moments freshness genuinely matters. Reserve fully dynamic rendering for the pages where staleness is actually unacceptable: a checkout page showing live inventory, a dashboard showing a user's own private data, anything where "up to an hour old" would be a real, user-visible bug rather than an imperceptible delay.
Key Takeaways
ISR sits in the gap between "fully static, blazing fast, but goes stale" and "fully dynamic, always fresh, but slower and more expensive." For most content-driven pages — the ones your product owner obsesses over freshness for exactly zero seconds after they hit publish — it's the correct default, not a compromise.
| Scenario | What to reach for |
|---|---|
| Content changes occasionally, staleness of minutes/hours is fine | Time-based revalidate with a generous interval |
| You know the exact moment data changed (a publish action, an edit) | revalidatePath or revalidateTag from a Server Action |
| The same data feeds multiple routes/components | Tag it once with next: { tags: [...] }, invalidate by tag |
| A transient upstream failure during regeneration | Handled automatically — stale content keeps serving, retried next request |
| Debugging whether a page is actually cached | Check the x-nextjs-cache response header |
| Running multiple server instances | Configure a shared custom cache handler, don't rely on the default file-system cache |
| Fully static export or Edge runtime | ISR isn't available — you'll need dynamic rendering or a Node.js runtime instead |
Get the revalidation interval right, understand that a stale page is always the fallback rather than an error page, and know which of your fetches are quietly forcing an entire route dynamic — and ISR stops being a magic flag you set and forget, and becomes a caching strategy you can actually reason about under load.


