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.
What ISR Actually Does
ISR lets you pre-render pages at build time and then update them in the background when they go stale. The key behaviors:
- First request after cache expiry: serve the stale page immediately (no wait), trigger background regeneration
- Subsequent requests while regenerating: still serve stale content
- After regeneration completes: new requests get the fresh page This "stale-while-revalidate" approach means users never wait for a slow page render — but it also means they might see outdated content for a period after data changes.
Time-Based ISR
// 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:
- Content that changes on a predictable schedule (daily, hourly)
- Content where slightly stale data is acceptable
- High-traffic pages where you want the performance benefits of static serving When it doesn't work well:
- Content that changes unpredictably (user actions, CMS updates)
- Content where staleness is confusing or misleading
- Low-traffic pages that might not get re-validated within the revalidation window
The Problem With Time-Based ISR
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
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.
Setting Up the Revalidation Endpoint
// 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 — The Better Approach
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 });
}
Connecting to a CMS Webhook
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.
The 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.
Combining Both Strategies
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.
Testing Revalidation
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.
Common ISR Mistakes
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.
Summary
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.
Top comments (1)
Great breakdown of ISR and on-demand revalidation. Caching is often easy to add but much harder to design correctly when freshness actually matters. The balance between performance and data consistency is where good architecture decisions make the biggest difference.
I like the focus on avoiding stale content without giving up the benefits of static generation. Patterns like targeted revalidation allow teams to update only what changed instead of rebuilding everything.
The bigger lesson is that caching is not just a performance optimization — it becomes part of the application's data consistency strategy. Thinking about invalidation, ownership of updates, and when users need fresh data is essential for production systems. Great article!