Incremental Static Regeneration (ISR) is one of the more powerful caching strategies in Next.js — and one that's often misconfigured in ways that either make it useless or cause unexpected staleness. On-demand revalidation, added in later Next.js versions, fixes the main problems with time-based ISR. Here's how both work and when to use which.
The pattern I'm describing here runs in production powering the free image generation for Etsy sellers use case where stale gallery data would be confusing.
ISR lets you pre-render pages at build time and then update them in the background when they go stale. The key behaviors:
// app/blog/[slug]/page.js
export const revalidate = 3600; // Revalidate at most every hour
export default async function BlogPost({ params }) {
const post = await getPost(params.slug);
return <Article post={post} />;
}
Or for more granular control in fetch calls:
async function getPost(slug) {
const res = await fetch(`https://api.example.com/posts/${slug}`, {
next: { revalidate: 3600 }
});
return res.json();
}
When time-based ISR works well:
The main failure mode: an editor publishes an urgent update (price change, event cancellation, product correction) and it doesn't appear for up to an hour because the revalidation window hasn't expired.
This is where on-demand revalidation solves a real problem.
On-demand revalidation lets you programmatically invalidate specific cached pages or data when you know the underlying data has changed — from a webhook, a CMS publish event, or an admin action.
// app/api/revalidate/route.ts
import { NextRequest, NextResponse } from 'next/server';
import { revalidatePath, revalidateTag } from 'next/cache';
const REVALIDATION_SECRET = process.env.REVALIDATION_SECRET;
export async function POST(request: NextRequest) {
const body = await request.json();
// Verify the request is from a trusted source
if (body.secret !== REVALIDATION_SECRET) {
return NextResponse.json({ message: 'Invalid secret' }, { status: 401 });
}
try {
if (body.type === 'path') {
// Revalidate a specific path
revalidatePath(body.path);
} else if (body.type === 'tag') {
// Revalidate all pages tagged with this cache tag
revalidateTag(body.tag);
}
return NextResponse.json({ revalidated: true });
} catch (error) {
return NextResponse.json({ message: 'Error revalidating' }, { status: 500 });
}
}
Cache tags let you invalidate multiple related pages at once:
// Tag fetches with semantic cache tags
async function getPost(slug) {
const res = await fetch(`https://api.example.com/posts/${slug}`, {
next: {
tags: [`post-${slug}`, 'posts'],
revalidate: false // Never time-expire — rely on on-demand only
}
});
return res.json();
}
async function getPostList() {
const res = await fetch('https://api.example.com/posts', {
next: {
tags: ['posts'],
revalidate: false
}
});
return res.json();
}
// Revalidate endpoint using tags
export async function POST(request: NextRequest) {
const body = await request.json();
if (body.secret !== REVALIDATION_SECRET) {
return NextResponse.json({ message: 'Invalid secret' }, { status: 401 });
}
// When a post is updated, invalidate both the specific post and the list
if (body.event === 'post.updated') {
revalidateTag(`post-${body.slug}`); // Just this post
revalidateTag('posts'); // Also the list
}
if (body.event === 'post.deleted') {
revalidateTag(`post-${body.slug}`);
revalidateTag('posts');
}
return NextResponse.json({ revalidated: true });
}
Most headless CMS platforms (Contentful, Sanity, Notion CMS, etc.) support webhooks on content changes. Configure the webhook to call your revalidation endpoint:
Webhook URL: https://yourdomain.com/api/revalidate
Method: POST
Body:
{
"secret": "your-secret-here",
"event": "post.updated",
"slug": "the-updated-post-slug"
}
When content is published or updated in the CMS, the webhook fires, your endpoint revalidates the affected tags, and the next request to the relevant pages triggers fresh data fetching.
revalidatePath
vs revalidateTag
Decision ** revalidatePath:** Invalidates a specific URL path. Simple, direct.
revalidatePath('/blog/my-post-slug');
revalidatePath('/blog'); // Revalidate the blog index
revalidatePath('/', 'layout'); // Revalidate everything that uses the root layout
** revalidateTag:** Invalidates all pages that fetched data with that cache tag. More flexible, works across related pages.
Use revalidatePath
when you know exactly which URL needs updating. Use revalidateTag
when a data change affects multiple pages you can't enumerate upfront.
In practice, a combination works well:
// Default: moderate time-based revalidation as a safety net
export const revalidate = 3600;
// Specific fetches: tagged for on-demand invalidation
async function getProductData(id) {
const res = await fetch(`/api/products/${id}`, {
next: {
tags: [`product-${id}`, 'products'],
revalidate: 3600 // Time-based as fallback
}
});
return res.json();
}
The time-based revalidation ensures even if webhook delivery fails, the cache eventually refreshes. The on-demand revalidation ensures intentional updates appear immediately when the CMS fires a webhook.
Manual testing for your revalidation endpoint:
curl -X POST https://your-domain.com/api/revalidate \
-H "Content-Type: application/json" \
-d '{"secret": "your-secret", "type": "tag", "tag": "posts"}'
Check the response: {"revalidated": true}
. Then request the previously cached page — it should now reflect current data.
In development: revalidateTag
and revalidatePath
still work in next dev
but the behavior is different from production — dev mode doesn't cache in the same way. Test revalidation in a production-like staging environment for accurate behavior validation.
Setting revalidate: 0. This disables caching entirely — equivalent to making every request server-rendered. Not ISR. Use
revalidate: false
if you want on-demand-only revalidation with no time-based expiry.Not securing the revalidation endpoint. Without the secret check, anyone can force-revalidate your cache, which is a minor DoS vector. Always validate the secret.
Revalidating too broadly. revalidatePath('/', 'layout')
revalidates everything — expensive and slow. Use specific paths or tags wherever possible.
Not handling revalidation failures. If the webhook fires but the revalidation endpoint returns an error, the stale content persists. Add monitoring for revalidation endpoint failures and consider retry logic in the CMS webhook settings.
Forgetting that revalidation doesn't guarantee immediate freshness. After revalidateTag
, the next request triggers regeneration. That user sees slightly old content during regeneration. Subsequent users see the fresh version. This is expected behavior, not a bug — but product teams need to understand it.
Time-based ISR (revalidate: 3600
) is a simple, effective cache strategy for content that changes on a schedule. It has known limitations around unpredictable content changes.
On-demand revalidation via cache tags is the right solution when you need precise control: invalidate specific data when it changes, triggered by CMS webhooks or application events. Use both together — time-based as a safety net, on-demand for immediate intentional updates.