DEV Community

Cover image for ISR and On-Demand Revalidation in Next.js App Router — Keeping Static Pages Fresh
Aon infotech
Aon infotech

Posted on

ISR and On-Demand Revalidation in Next.js App Router — Keeping Static Pages Fresh

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'
});
Enter fullscreen mode Exit fullscreen mode

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} />;
}
Enter fullscreen mode Exit fullscreen mode

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} />;
}
Enter fullscreen mode Exit fullscreen mode

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 });
}
Enter fullscreen mode Exit fullscreen mode

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"}'
Enter fullscreen mode Exit fullscreen mode

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'] }
});
Enter fullscreen mode Exit fullscreen mode
// 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 });
}
Enter fullscreen mode Exit fullscreen mode

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();
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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}`);
}
Enter fullscreen mode Exit fullscreen mode

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>
  );
}
Enter fullscreen mode Exit fullscreen mode

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:

  1. Verify the revalidate valueconsole.log the revalidate export and confirm it's reaching the route
  2. Check the cache headerX-Nextjs-Cache: HIT means you're seeing cached content
  3. Verify webhook delivery — if using on-demand revalidation, check your CMS webhook logs for successful delivery
  4. Check the secret — webhook revalidation with a wrong secret returns 401 silently
  5. Test with next start — ISR doesn't work in next 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)