Static pages are fast, but they go stale. Incremental Static Regeneration (ISR) solves this by regenerating pages in the background after a set time interval. On-demand revalidation goes further — triggering regeneration immediately when data changes, rather than waiting for the time interval to expire.
Here's how both work in the App Router, with patterns from the content pipeline at AI image generation pipeline.
How Caching Works in App Router
Server Components in App Router are cached by default. The fetch cache determines how long a page stays fresh before Next.js regenerates it.
// Cached indefinitely (static) — the default
const data = await fetch('https://api.example.com/posts');
// Cached for 60 seconds, then regenerated
const data = await fetch('https://api.example.com/posts', {
next: { revalidate: 60 }
});
// Never cached — always fresh (like getServerSideProps)
const data = await fetch('https://api.example.com/posts', {
cache: 'no-store'
});
The revalidate value at the fetch level sets ISR for that data. Next.js serves cached pages until the revalidation time expires, then regenerates in the background on the next request.
Setting Revalidation at the Route Level
You can also set revalidation for an entire route using the exported revalidate constant:
// app/blog/[slug]/page.tsx
export const revalidate = 3600; // Regenerate every hour
export default async function BlogPost({ params }) {
const post = await getPost(params.slug);
return <Article post={post} />;
}
This applies to all fetches in the route unless individual fetches override it. The most restrictive value wins — if the route exports revalidate = 3600 but a fetch uses cache: 'no-store', that fetch bypasses caching.
Generating Static Params
For dynamic routes, generateStaticParams tells Next.js which paths to pre-render at build time:
// app/blog/[slug]/page.tsx
export async function generateStaticParams() {
const posts = await getAllPosts();
return posts.map(post => ({
slug: post.slug,
}));
}
export const revalidate = 3600;
export const dynamicParams = true; // Allow params not in generateStaticParams
export default async function BlogPost({ params }) {
const post = await getPost(params.slug);
if (!post) {
notFound();
}
return <Article post={post} />;
}
With dynamicParams = true (the default), requests for slugs not in generateStaticParams are rendered on-demand and then cached. Set dynamicParams = false to return 404 for any path not pre-generated.
On-Demand Revalidation
Time-based ISR regenerates on a schedule regardless of whether data has actually changed. On-demand revalidation triggers regeneration immediately when you know data has changed — more efficient and more responsive.
Two functions: revalidatePath and revalidateTag.
revalidatePath
Invalidates the cache for a specific route:
// app/api/revalidate/route.ts
import { revalidatePath } from 'next/cache';
import { NextRequest, NextResponse } from 'next/server';
export async function POST(request: NextRequest) {
const secret = request.headers.get('x-revalidate-secret');
if (secret !== process.env.REVALIDATE_SECRET) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
const { path } = await request.json();
revalidatePath(path);
return NextResponse.json({ revalidated: true, path });
}
Call this from your CMS webhook when content is updated:
curl -X POST https://your-site.com/api/revalidate \
-H "x-revalidate-secret: your-secret" \
-H "Content-Type: application/json" \
-d '{"path": "/blog/my-post-slug"}'
revalidateTag
More granular — tag fetches with labels and invalidate all tagged fetches at once:
// Tagging fetches
const post = await fetch(`https://api.example.com/posts/${slug}`, {
next: { tags: ['post', `post-${slug}`] }
});
const allPosts = await fetch('https://api.example.com/posts', {
next: { tags: ['posts-list'] }
});
// app/api/revalidate/route.ts
import { revalidateTag } from 'next/cache';
export async function POST(request: NextRequest) {
const { tag } = await request.json();
revalidateTag(tag);
return NextResponse.json({ revalidated: true });
}
When a post is updated in your CMS, call the revalidation endpoint with the specific post tag. Only that post's data is invalidated — the posts list and other posts remain cached.
Combining ISR and On-Demand Revalidation
The pattern that works best for content sites: time-based ISR as a fallback, on-demand revalidation for immediate updates.
// app/blog/[slug]/page.tsx
export const revalidate = 86400; // Fallback: regenerate daily
// Fetches tagged for on-demand invalidation
async function getPost(slug: string) {
const res = await fetch(`https://api.example.com/posts/${slug}`, {
next: {
revalidate: 86400,
tags: [`post-${slug}`, 'posts']
}
});
return res.json();
}
Daily ISR ensures pages stay fresh even if the webhook fails. On-demand revalidation ensures immediate updates when you need them.
Testing Cache Behavior
Next.js provides the X-Nextjs-Cache response header:
-
HIT— served from cache -
MISS— cache miss, generated fresh -
STALE— served stale while regenerating in background
curl -I https://your-site.com/blog/post-slug | grep x-nextjs-cache
# x-nextjs-cache: HIT
In development (next dev), caching is disabled by default so you always get fresh data. To test ISR behavior, build and run locally with next build && next start.
When to Use Each Approach
Static (no revalidate): Pages that never change. Legal documents, about pages, marketing pages with manual deploy updates.
Time-based ISR: Content that changes on a predictable schedule. News sites (hourly), ecommerce inventory (15-minute intervals), dashboards.
On-demand revalidation: CMS-driven content where you want immediate updates on publish without waiting for an interval. Blog posts, product pages, anything with a webhook-capable CMS.
cache: 'no-store': User-specific data, real-time data, content that must always be fresh. Use sparingly — each request hits the origin.
Revalidation in Server Actions
Server Actions can trigger revalidation directly after mutations — no separate API route needed:
// app/actions/posts.ts
'use server';
import { revalidatePath, revalidateTag } from 'next/cache';
export async function updatePost(id: string, data: PostUpdateData) {
await db.posts.update({ where: { id }, data });
// Invalidate the specific post and the posts list
revalidateTag(`post-${id}`);
revalidateTag('posts-list');
revalidatePath(`/blog/${data.slug}`);
}
This is the cleanest pattern for CMS-style applications built entirely in Next.js — the mutation and revalidation happen in the same Server Action, no webhook configuration needed.
Summary
ISR in App Router: set revalidate on fetch calls or export it as a route constant. Time-based ISR regenerates on a schedule; on-demand revalidation (revalidatePath, revalidateTag) triggers immediate regeneration when data changes.
The combination — time-based ISR as fallback plus on-demand revalidation from CMS webhooks or Server Actions — gives you both immediate updates and resilience against missed webhooks.
Handling Cache Misses Gracefully
When ISR generates a page for the first time (cache miss), there's a brief period while the page is being generated. Configure a loading UI to prevent users from seeing blank content:
// app/blog/[slug]/loading.tsx
export default function PostLoading() {
return (
<div className="max-w-3xl mx-auto px-4 py-12">
<div className="h-8 bg-neutral-100 rounded w-2/3 animate-pulse mb-4" />
<div className="h-4 bg-neutral-100 rounded w-full animate-pulse mb-2" />
<div className="h-4 bg-neutral-100 rounded w-5/6 animate-pulse mb-2" />
<div className="h-4 bg-neutral-100 rounded w-4/6 animate-pulse" />
</div>
);
}
The loading UI shows during the initial generation of uncached paths. Subsequent requests serve from cache instantly.
Debugging Stale Content
If content isn't updating when expected, check these in order:
-
Verify the revalidate value —
console.logtherevalidateexport and confirm it's reaching the route -
Check the cache header —
X-Nextjs-Cache: HITmeans you're seeing cached content - Verify webhook delivery — if using on-demand revalidation, check your CMS webhook logs for successful delivery
- Check the secret — webhook revalidation with a wrong secret returns 401 silently
-
Test with
next start— ISR doesn't work innext dev; always test with a production build locally
Most ISR issues are either the route not opting into revalidation, or on-demand webhooks not being delivered correctly.
Top comments (0)