# Next.js ISR and On-Demand Revalidation — Caching Without Staleness

> Source: <https://dev.to/aon_infotech_3a1b6ff525fc/nextjs-isr-and-on-demand-revalidation-caching-without-staleness-11in>
> Published: 2026-07-11 11:14:10+00:00

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](https://pixova.io/blog/free-ai-image-generator-for-etsy-sellers) use case where stale gallery data would be confusing.

ISR lets you pre-render pages at build time and then update them in the background when they go stale. The key behaviors:

``` js
// 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:

``` js
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:**

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 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.

``` js
// 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 let you invalidate multiple related pages at once:

``` js
// 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 });
}
```

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.

`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.

In practice, a combination works well:

``` js
// 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.

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.

**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.

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.
