{"slug": "next-js-isr-and-on-demand-revalidation-caching-without-staleness", "title": "Next.js ISR and On-Demand Revalidation — Caching Without Staleness", "summary": "A developer explains how to use Incremental Static Regeneration (ISR) and on-demand revalidation in Next.js to achieve caching without staleness, highlighting the limitations of time-based ISR and demonstrating how on-demand revalidation via webhooks can instantly update cached pages when data changes.", "body_md": "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.\n\nThe 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.\n\nISR lets you pre-render pages at build time and then update them in the background when they go stale. The key behaviors:\n\n``` js\n// app/blog/[slug]/page.js\nexport const revalidate = 3600; // Revalidate at most every hour\n\nexport default async function BlogPost({ params }) {\n  const post = await getPost(params.slug);\n  return <Article post={post} />;\n}\n```\n\nOr for more granular control in fetch calls:\n\n``` js\nasync function getPost(slug) {\n  const res = await fetch(`https://api.example.com/posts/${slug}`, {\n    next: { revalidate: 3600 }\n  });\n  return res.json();\n}\n```\n\n**When time-based ISR works well:**\n\nThe 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.\n\nThis is where on-demand revalidation solves a real problem.\n\nOn-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.\n\n``` js\n// app/api/revalidate/route.ts\nimport { NextRequest, NextResponse } from 'next/server';\nimport { revalidatePath, revalidateTag } from 'next/cache';\n\nconst REVALIDATION_SECRET = process.env.REVALIDATION_SECRET;\n\nexport async function POST(request: NextRequest) {\n  const body = await request.json();\n\n  // Verify the request is from a trusted source\n  if (body.secret !== REVALIDATION_SECRET) {\n    return NextResponse.json({ message: 'Invalid secret' }, { status: 401 });\n  }\n\n  try {\n    if (body.type === 'path') {\n      // Revalidate a specific path\n      revalidatePath(body.path);\n    } else if (body.type === 'tag') {\n      // Revalidate all pages tagged with this cache tag\n      revalidateTag(body.tag);\n    }\n\n    return NextResponse.json({ revalidated: true });\n  } catch (error) {\n    return NextResponse.json({ message: 'Error revalidating' }, { status: 500 });\n  }\n}\n```\n\nCache tags let you invalidate multiple related pages at once:\n\n``` js\n// Tag fetches with semantic cache tags\nasync function getPost(slug) {\n  const res = await fetch(`https://api.example.com/posts/${slug}`, {\n    next: { \n      tags: [`post-${slug}`, 'posts'],\n      revalidate: false // Never time-expire — rely on on-demand only\n    }\n  });\n  return res.json();\n}\n\nasync function getPostList() {\n  const res = await fetch('https://api.example.com/posts', {\n    next: { \n      tags: ['posts'],\n      revalidate: false\n    }\n  });\n  return res.json();\n}\n// Revalidate endpoint using tags\nexport async function POST(request: NextRequest) {\n  const body = await request.json();\n\n  if (body.secret !== REVALIDATION_SECRET) {\n    return NextResponse.json({ message: 'Invalid secret' }, { status: 401 });\n  }\n\n  // When a post is updated, invalidate both the specific post and the list\n  if (body.event === 'post.updated') {\n    revalidateTag(`post-${body.slug}`); // Just this post\n    revalidateTag('posts'); // Also the list\n  }\n\n  if (body.event === 'post.deleted') {\n    revalidateTag(`post-${body.slug}`);\n    revalidateTag('posts');\n  }\n\n  return NextResponse.json({ revalidated: true });\n}\n```\n\nMost headless CMS platforms (Contentful, Sanity, Notion CMS, etc.) support webhooks on content changes. Configure the webhook to call your revalidation endpoint:\n\n**Webhook URL:** `https://yourdomain.com/api/revalidate`\n\n**Method:** POST\n\n**Body:**\n\n```\n{\n  \"secret\": \"your-secret-here\",\n  \"event\": \"post.updated\",\n  \"slug\": \"the-updated-post-slug\"\n}\n```\n\nWhen 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.\n\n`revalidatePath`\n\nvs `revalidateTag`\n\nDecision\n** revalidatePath:** Invalidates a specific URL path. Simple, direct.\n\n```\nrevalidatePath('/blog/my-post-slug');\nrevalidatePath('/blog'); // Revalidate the blog index\nrevalidatePath('/', 'layout'); // Revalidate everything that uses the root layout\n```\n\n** revalidateTag:** Invalidates all pages that fetched data with that cache tag. More flexible, works across related pages.\n\nUse `revalidatePath`\n\nwhen you know exactly which URL needs updating. Use `revalidateTag`\n\nwhen a data change affects multiple pages you can't enumerate upfront.\n\nIn practice, a combination works well:\n\n``` js\n// Default: moderate time-based revalidation as a safety net\nexport const revalidate = 3600;\n\n// Specific fetches: tagged for on-demand invalidation\nasync function getProductData(id) {\n  const res = await fetch(`/api/products/${id}`, {\n    next: { \n      tags: [`product-${id}`, 'products'],\n      revalidate: 3600 // Time-based as fallback\n    }\n  });\n  return res.json();\n}\n```\n\nThe 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.\n\nManual testing for your revalidation endpoint:\n\n```\ncurl -X POST https://your-domain.com/api/revalidate \\\n  -H \"Content-Type: application/json\" \\\n  -d '{\"secret\": \"your-secret\", \"type\": \"tag\", \"tag\": \"posts\"}'\n```\n\nCheck the response: `{\"revalidated\": true}`\n\n. Then request the previously cached page — it should now reflect current data.\n\n**In development:** `revalidateTag`\n\nand `revalidatePath`\n\nstill work in `next dev`\n\nbut 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.\n\n**Setting revalidate: 0.** This disables caching entirely — equivalent to making every request server-rendered. Not ISR. Use\n\n`revalidate: false`\n\nif 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.\n\n**Revalidating too broadly.** `revalidatePath('/', 'layout')`\n\nrevalidates everything — expensive and slow. Use specific paths or tags wherever possible.\n\n**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.\n\n**Forgetting that revalidation doesn't guarantee immediate freshness.** After `revalidateTag`\n\n, 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.\n\nTime-based ISR (`revalidate: 3600`\n\n) is a simple, effective cache strategy for content that changes on a schedule. It has known limitations around unpredictable content changes.\n\nOn-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.", "url": "https://wpnews.pro/news/next-js-isr-and-on-demand-revalidation-caching-without-staleness", "canonical_source": "https://dev.to/aon_infotech_3a1b6ff525fc/nextjs-isr-and-on-demand-revalidation-caching-without-staleness-11in", "published_at": "2026-07-11 11:14:10+00:00", "updated_at": "2026-07-11 11:43:53.795420+00:00", "lang": "en", "topics": ["developer-tools"], "entities": ["Next.js", "Contentful", "Sanity", "Notion CMS"], "alternates": {"html": "https://wpnews.pro/news/next-js-isr-and-on-demand-revalidation-caching-without-staleness", "markdown": "https://wpnews.pro/news/next-js-isr-and-on-demand-revalidation-caching-without-staleness.md", "text": "https://wpnews.pro/news/next-js-isr-and-on-demand-revalidation-caching-without-staleness.txt", "jsonld": "https://wpnews.pro/news/next-js-isr-and-on-demand-revalidation-caching-without-staleness.jsonld"}}