Skip to content
Codeloom
Next.js

Next.js ISR and On-Demand Revalidation Explained

Master Incremental Static Regeneration and on-demand revalidation in Next.js to serve fresh content without rebuilding your entire site.

·7 min read · By Codeloom
Intermediate 11 min read

What you'll learn

  • How ISR serves static pages while keeping content fresh
  • Time-based vs on-demand revalidation strategies
  • Implementing revalidatePath and revalidateTag
  • Cache management patterns for CMS-driven sites

Prerequisites

None — this post is self-contained.

Static sites are fast but stale. Server-rendered pages are fresh but slow. Incremental Static Regeneration (ISR) gives you both: static pages that update automatically in the background. Combined with on-demand revalidation, ISR lets you serve cached content with near-instant updates when your data changes.

How ISR Works

ISR generates static pages at build time and then regenerates individual pages in the background when they are requested after a configurable time interval. The first visitor after the revalidation interval gets the stale (cached) page while a new version is generated. Subsequent visitors get the fresh page.

// app/products/[id]/page.tsx
interface Product {
  id: string;
  name: string;
  price: number;
  updatedAt: string;
}

async function getProduct(id: string): Promise<Product> {
  const res = await fetch(`https://api.example.com/products/${id}`, {
    next: { revalidate: 60 }, // Revalidate at most every 60 seconds
  });

  if (!res.ok) throw new Error('Failed to fetch product');
  return res.json();
}

export default async function ProductPage({
  params,
}: {
  params: Promise<{ id: string }>;
}) {
  const { id } = await params;
  const product = await getProduct(id);

  return (
    <div>
      <h1>{product.name}</h1>
      <p>Price: ${product.price}</p>
      <p className="text-sm text-gray-500">
        Last updated: {product.updatedAt}
      </p>
    </div>
  );
}

The next: { revalidate: 60 } option tells Next.js to serve the cached version for 60 seconds. After 60 seconds, the next request triggers a background regeneration. This is called stale-while-revalidate.

Route Segment Config

You can also set revalidation at the route segment level, which applies to all fetch calls in that page:

// app/blog/[slug]/page.tsx
export const revalidate = 3600; // Revalidate every hour

export default async function BlogPost({
  params,
}: {
  params: Promise<{ slug: string }>;
}) {
  const { slug } = await params;
  const post = await fetch(`https://cms.example.com/posts/${slug}`).then(
    (r) => r.json()
  );

  return (
    <article>
      <h1>{post.title}</h1>
      <div dangerouslySetInnerHTML={{ __html: post.content }} />
    </article>
  );
}

On-Demand Revalidation with revalidatePath

Time-based revalidation is simple but imprecise. If your CMS content changes, you do not want users to wait up to 60 seconds for the update. On-demand revalidation lets you invalidate specific pages immediately.

// app/api/revalidate/route.ts
import { NextRequest, NextResponse } from 'next/server';
import { revalidatePath } from 'next/cache';

export async function POST(request: NextRequest) {
  const body = await request.json();
  const secret = request.headers.get('x-revalidation-secret');

  // Verify the webhook secret
  if (secret !== process.env.REVALIDATION_SECRET) {
    return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
  }

  const { path } = body;

  if (!path) {
    return NextResponse.json(
      { error: 'Missing path parameter' },
      { status: 400 }
    );
  }

  revalidatePath(path);

  return NextResponse.json({
    revalidated: true,
    path,
    timestamp: Date.now(),
  });
}

Call this endpoint from your CMS webhook when content is published:

curl -X POST https://your-site.com/api/revalidate \
  -H "Content-Type: application/json" \
  -H "x-revalidation-secret: your-secret" \
  -d '{"path": "/blog/my-updated-post"}'

Revalidating Layouts and Nested Routes

revalidatePath can target different scopes:

import { revalidatePath } from 'next/cache';

// Revalidate a specific page
revalidatePath('/blog/my-post');

// Revalidate all pages under a layout
revalidatePath('/blog', 'layout');

// Revalidate all pages matching a dynamic segment
revalidatePath('/products/[id]', 'page');

// Revalidate everything
revalidatePath('/', 'layout');

Tag-Based Revalidation with revalidateTag

Tags let you group cached data across multiple pages and invalidate them together. This is the most powerful revalidation strategy.

// app/products/[id]/page.tsx
async function getProduct(id: string) {
  const res = await fetch(`https://api.example.com/products/${id}`, {
    next: {
      tags: [`product-${id}`, 'products'],
    },
  });
  return res.json();
}

// app/products/page.tsx (product listing)
async function getProducts() {
  const res = await fetch('https://api.example.com/products', {
    next: {
      tags: ['products'],
    },
  });
  return res.json();
}

Now you can invalidate selectively:

// app/api/cms-webhook/route.ts
import { NextRequest, NextResponse } from 'next/server';
import { revalidateTag } from 'next/cache';

export async function POST(request: NextRequest) {
  const body = await request.json();

  // Verify webhook authenticity
  const secret = request.headers.get('x-webhook-secret');
  if (secret !== process.env.WEBHOOK_SECRET) {
    return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
  }

  switch (body.event) {
    case 'product.updated':
      // Revalidate only the specific product and the listing
      revalidateTag(`product-${body.productId}`);
      revalidateTag('products');
      break;

    case 'product.created':
    case 'product.deleted':
      // Revalidate only the listing
      revalidateTag('products');
      break;

    case 'settings.updated':
      // Revalidate everything tagged with settings
      revalidateTag('site-settings');
      break;
  }

  return NextResponse.json({ revalidated: true });
}

Revalidation in Server Actions

Server Actions can trigger revalidation directly, which is useful for forms and mutations:

// app/products/[id]/edit/page.tsx
import { revalidatePath, revalidateTag } from 'next/cache';

async function updateProduct(formData: FormData) {
  'use server';

  const id = formData.get('id') as string;
  const name = formData.get('name') as string;
  const price = parseFloat(formData.get('price') as string);

  await fetch(`https://api.example.com/products/${id}`, {
    method: 'PUT',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({ name, price }),
  });

  // Revalidate the product page and listing
  revalidateTag(`product-${id}`);
  revalidatePath('/products');
}

export default function EditProduct({ product }: { product: any }) {
  return (
    <form action={updateProduct}>
      <input type="hidden" name="id" value={product.id} />
      <input name="name" defaultValue={product.name} />
      <input name="price" type="number" defaultValue={product.price} />
      <button type="submit">Save</button>
    </form>
  );
}

Combining Time-Based and On-Demand Revalidation

The best strategy for most applications uses both approaches together. Time-based revalidation acts as a safety net, while on-demand revalidation provides instant updates.

// Fetch with both a tag and a time-based revalidation
async function getPost(slug: string) {
  const res = await fetch(`https://cms.example.com/posts/${slug}`, {
    next: {
      tags: [`post-${slug}`, 'blog'],
      revalidate: 3600, // Safety net: revalidate at least every hour
    },
  });
  return res.json();
}

If the CMS webhook fires, the page updates immediately via revalidateTag. If the webhook fails or is not configured, the page still updates within an hour via time-based revalidation.

Generating Static Pages at Build Time

Use generateStaticParams to pre-render pages at build time. New pages that are not pre-rendered will be generated on the first request and cached:

// app/blog/[slug]/page.tsx
export async function generateStaticParams() {
  const posts = await fetch('https://cms.example.com/posts').then((r) =>
    r.json()
  );

  // Pre-render the 50 most recent posts at build time
  return posts.slice(0, 50).map((post: any) => ({
    slug: post.slug,
  }));
}

// dynamicParams defaults to true, meaning non-pre-rendered
// slugs are generated on demand and cached

Set export const dynamicParams = false to return a 404 for any slug not returned by generateStaticParams.

Debugging Revalidation

To verify that revalidation is working correctly:

// Add cache status headers in next.config.js
// next.config.js
module.exports = {
  headers() {
    return [
      {
        source: '/:path*',
        headers: [
          {
            key: 'x-next-cache-tags',
            value: '', // Next.js populates this automatically in dev
          },
        ],
      },
    ];
  },
};

In development, check the terminal output for [cache: HIT] and [cache: MISS] indicators. In production on Vercel, use the x-vercel-cache response header: HIT means the response was served from cache, STALE means a revalidation was triggered, and MISS means the page was generated fresh.

You can also log revalidation events:

// app/api/revalidate/route.ts
export async function POST(request: NextRequest) {
  const { path, tag } = await request.json();

  console.log(`Revalidation triggered: path=${path}, tag=${tag}, time=${new Date().toISOString()}`);

  if (tag) {
    revalidateTag(tag);
  } else if (path) {
    revalidatePath(path);
  }

  return NextResponse.json({ revalidated: true });
}

Wrapping Up

ISR bridges the gap between static and dynamic rendering. Time-based revalidation with next: { revalidate } gives you automatic background updates with minimal configuration. On-demand revalidation with revalidatePath and revalidateTag gives you instant cache invalidation triggered by webhooks or server actions. Use tags for fine-grained control over which pages update when specific data changes. Combine both strategies for a system that is fast by default and fresh when it matters.