{post.title}
{post.content}
Last updated: {new Date(post.updatedAt).toISOString()}
Common reasons why ISR fails to regenerate pages in Next.js and how to fix cache, revalidate, and deployment configuration issues
Incremental Static Regeneration (ISR) is one of Next.js's most valuable features for content-heavy sites. It allows you to update static pages after build time without triggering a full redeployment. However, ISR is also one of the most misunderstood features, and when it does not work, debugging it can be extremely frustrating.
On a project for a news aggregation site, ISR simply refused to regenerate pages. The content stayed stale for hours even after the revalidate period had elapsed. After extensive testing across local, staging, and production environments, I discovered that the behavior differed dramatically between deployment platforms.
Pages configured with ISR were not regenerating after the specified revalidate interval:
// app/blog/[slug]/page.tsx
export const revalidate = 60 // Revalidate every 60 seconds
async function getPost(slug: string) {
const res = await fetch(`https://api.example.com/posts/${slug}`)
return res.json()
}
export default async function BlogPost({ params }: { params: { slug: string } }) {
const post = await getPost(params.slug)
return (
{post.title}
{post.content}
Last updated: {new Date(post.updatedAt).toISOString()}
)
}The last updated timestamp in the HTML remained unchanged hours after the source content was updated. Running next build locally showed the pages were correctly generated as static, but the revalidation was never triggered.
# Checking the build output
Route (app) Size First Load JS
β β /blog/[slug] 0 B 0 kB
β β / 0 B 0 kB
β (Static) prerendered as static contentAfter systematic investigation, I identified three root causes:
1. getStaticProps vs App Router data fetching. In Pages Router, ISR requires getStaticProps with revalidate. In App Router, the revalidate export at the top of the file controls the behavior. The two systems are completely different.
2. Cache configuration overriding ISR. The fetch call had no explicit cache configuration, which defaulted to force-cache in some environments and no-store in others, leading to inconsistent behavior.
3. Platform-specific ISR behavior. ISR works differently on Vercel, Netlify, and self-hosted deployments. The revalidation is handled at the platform level, not by Next.js itself in production.
Fix 1: Correct App Router ISR configuration
// app/blog/[slug]/page.tsx
// Option A: Time-based revalidation
export const revalidate = 60
async function getPost(slug: string) {
const res = await fetch(`https://api.example.com/posts/${slug}`, {
next: { revalidate: 60 }, // Also revalidate at the fetch level
})
if (!res.ok) throw new Error('Failed to fetch post')
return res.json()
}
export default async function BlogPost({
params,
}: {
params: { slug: string }
}) {
const post = await getPost(params.slug)
return (
{post.title}
)
}Fix 2: On-demand revalidation with webhook
// app/api/revalidate/route.ts
import { revalidatePath, revalidateTag } from 'next/cache'
import { NextRequest, NextResponse } from 'next/server'
export async function POST(request: NextRequest) {
const body = await request.json()
const secret = request.headers.get('x-revalidate-secret')
if (secret !== process.env.REVALIDATE_SECRET) {
return NextResponse.json({ error: 'Invalid secret' }, { status: 401 })
}
try {
// Revalidate by path
if (body.path) {
revalidatePath(body.path)
return NextResponse.json({ revalidated: true, path: body.path })
}
// Revalidate by tag
if (body.tag) {
revalidateTag(body.tag)
return NextResponse.json({ revalidated: true, tag: body.tag })
}
return NextResponse.json({ error: 'Missing path or tag' }, { status: 400 })
} catch (error) {
return NextResponse.json({ error: 'Revalidation failed' }, { status: 500 })
}
}// app/blog/[slug]/page.tsx
async function getPost(slug: string) {
const res = await fetch(`https://api.example.com/posts/${slug}`, {
next: {
revalidate: 60,
tags: [`post-${slug}`], // Tag for on-demand revalidation
},
})
if (!res.ok) throw new Error('Failed to fetch post')
return res.json()
}Fix 3: Ensure consistent cache behavior
// lib/fetchWithRevalidation.ts
interface FetchOptions {
revalidate?: number | false
tags?: string[]
}
export async function fetchWithRevalidation(
url: string,
options: FetchOptions = {}
): Promise {
const { revalidate = 60, tags = [] } = options
const res = await fetch(url, {
next: {
revalidate,
tags,
},
})
if (!res.ok) {
throw new Error(`Fetch failed: ${res.status} ${res.statusText}`)
}
return res.json()
} Fix 4: Debug ISR in development
# Start dev server with debug logging
NEXT_DEBUG_BUILD=1 next dev
# Check which pages are cached
curl -I http://localhost:3000/blog/my-post
# Look for x-nextjs-cache headerISR behavior varies by platform. Test your ISR configuration on the actual deployment platform, not just locally. Local development always shows fresh content.
Use on-demand revalidation for content updates. Time-based revalidation is wasteful if content does not change frequently. Webhook-based revalidation is more efficient and more predictable.
Tag-based revalidation is more precise. Instead of revalidating an entire path, use tags to revalidate specific data fetches.
Monitor the x-nextjs-cache header in production responses. Values of HIT, STALE, or MISS tell you exactly what the cache is doing.
Set dynamicParams for dynamic routes to control behavior when a new slug is accessed:
// app/blog/[slug]/page.tsx
export const dynamicParams = true // Allow new slugs
// or
export const dynamicParams = false // Return 404 for unknown slugsThis blog does not accept any external sponsorships, affiliate marketing, or ad revenue.