On-demand revalidation is the feature that makes “static + ISR” feel like a CMS. An editor publishes in Sanity or pushes a Markdown commit, a webhook hits your Next.js app, and the affected page refreshes in seconds with no redeploy and no full rebuild. Once you have it, running a full deploy just to fix a typo feels absurd. This article wires it up correctly for Next.js 16 (released October 2025; the cache APIs below went stable in 16.2, current as of June 2026).
TL;DR
- Add one
POST /api/revalidateroute handler, guarded by a header secret, that callsrevalidatePathorrevalidateTagbased on the webhook payload. - Use
revalidatePath('/en/articles/[slug]')for a single page,revalidateTag('articles', 'max')for cross-cutting changes (a new post that should refresh the listing and home page). - In a webhook (route handler) you must use
revalidateTag/revalidatePath.updateTagis Server-Actions-only and is the right tool for in-app “read-your-own-writes” publishing. - Prove it with three
curlchecks: secret accepted, secret rejected, page content changed.
Background: the four primitives
Next.js 16’s next/cache exposes four invalidation functions. As of June 2026 (Next.js 16.2) the use cache cache APIs are stable, so the unstable_ prefixes are gone.
| Function | Where it runs | Scope | Behavior |
|---|---|---|---|
revalidatePath(path, type?) | Server Functions + Route Handlers | One page/layout path | Marks the path stale; refreshes on next visit |
revalidateTag(tag, profile?) | Server Functions + Route Handlers | Every cache entry with that tag | profile: 'max' = stale-while-revalidate; no profile = legacy immediate-expire |
updateTag(tag) | Server Actions only | Every cache entry with that tag | Expires immediately; next request waits for fresh data (read-your-own-writes) |
cacheTag(tag) | Inside a 'use cache' function | Tags that cache entry | Declares the tag the two above invalidate |
For a CMS webhook, the trigger is external, so it lives in a route handler and you use revalidatePath / revalidateTag. updateTag cannot be called there — it throws updateTag can only be called from within a Server Action. The host needs a persistent function cache: Vercel ships one, and Next.js 16.2’s stable Adapter API lets Netlify and Cloudflare (via the OpenNext adapter) support on-demand revalidation too.
How to tell you need this
- Editors complain that updates take 15 minutes (your full deploy time) to show up.
- You see
export const revalidate = 60on every page and wonder why the CDN hit rate is bad. - You have a CMS with a webhook button labeled “Publish” that does nothing useful.
- A 5,000-article site rebuilds every page when one changes, burning build minutes.
Quick verdict
For any content site with a CMS or scheduled updates, set up an on-demand revalidation route handler with a shared secret. Use revalidatePath for single articles and revalidateTag for cross-cutting changes (a new article triggers a home-page refresh).
Wire up the route handler
A minimal /api/revalidate route in the App Router:
// app/api/revalidate/route.ts
import { revalidatePath, revalidateTag } from 'next/cache';
import { NextResponse } from 'next/server';
import type { NextRequest } from 'next/server';
export async function POST(req: NextRequest) {
const secret = req.headers.get('x-revalidate-secret');
if (secret !== process.env.REVALIDATE_SECRET) {
return NextResponse.json({ ok: false, error: 'unauthorized' }, { status: 401 });
}
const body = await req.json().catch(() => ({}));
const { slug, tag, lang } = body as { slug?: string; tag?: string; lang?: string };
if (tag) {
// 'max' gives stale-while-revalidate: visitors keep seeing the old
// page while the new one renders in the background.
revalidateTag(tag, 'max');
return NextResponse.json({ ok: true, revalidated: { tag } });
}
if (slug) {
const path = `/${lang ?? 'en'}/articles/${slug}`;
revalidatePath(path);
return NextResponse.json({ ok: true, revalidated: { path } });
}
return NextResponse.json({ ok: false, error: 'missing slug or tag' }, { status: 400 });
}
Four details that matter:
- Type-only import for
NextRequest. Next.js 16 / TypeScript flags a value import that is used only as a type;import typekeeps the bundle clean. - Header secret over query param. A header does not show up in CDN access logs.
- No trailing slash on the path.
revalidatePathmatches the route file structure, not the URL bar, so/en/articles/my-slugis correct regardless of yourtrailingSlashconfig. For a literal path (no[slug]segment) you omit the secondtypeargument. - Bilingual paths. Pass
langso the EN and ZH versions revalidate independently instead of forcing a full-site refresh.
Tag cached data so revalidateTag works
revalidateTag only invalidates data that opted in with a matching tag. There are two ways to tag, both valid in Next.js 16.
If you fetch from an external CMS API, tag the fetch call:
// lib/content.ts
export async function getArticle(slug: string) {
const res = await fetch(`${process.env.CMS_URL}/articles/${slug}`, {
next: { tags: [`article:${slug}`, 'articles'] },
});
return res.json();
}
If you cache a whole function (a DB query, a Markdown read) with the stable 'use cache' directive, tag it with cacheTag:
// lib/content.ts
import { cacheTag } from 'next/cache';
export async function getAllArticles() {
'use cache';
cacheTag('articles');
const rows = await db.article.findMany({ orderBy: { publishedAt: 'desc' } });
return rows;
}
Either way, revalidateTag('article:my-slug', 'max') refreshes only that article’s data, and revalidateTag('articles', 'max') refreshes the listing page and every article that reads the articles tag.
Configure the CMS webhook
In Sanity / Contentful / Strapi / Storyblok, the recipe is the same:
URL: https://yourdomain.com/api/revalidate
Method: POST
Headers: x-revalidate-secret: <env REVALIDATE_SECRET>
Body: { "slug": "{{slug}}", "lang": "{{lang}}" }
Most CMSs expose {{slug}} and locale variables in webhook templates. If yours does not, send the document ID and look up the slug server-side in your route handler.
Test it end-to-end
Three checks before you call it done:
# 1. Route works with the right secret
curl -sX POST https://yourdomain.com/api/revalidate \
-H "x-revalidate-secret: $REVALIDATE_SECRET" \
-H "content-type: application/json" \
-d '{"slug":"my-test-article","lang":"en"}'
# {"ok":true,"revalidated":{"path":"/en/articles/my-test-article"}}
# 2. Route rejects without secret
curl -sX POST https://yourdomain.com/api/revalidate \
-H "content-type: application/json" \
-d '{"slug":"my-test-article"}'
# {"ok":false,"error":"unauthorized"}
# 3. Page actually updated
curl -s https://yourdomain.com/en/articles/my-test-article/ | grep "key phrase from edit"
Common mistakes
- Putting the secret in the URL (
/api/revalidate?secret=xxx). It leaks into CDN logs, Vercel analytics, and HTTPRefererheaders on outbound links. Use a header. - Passing a dynamic route pattern without the
typeargument.revalidatePath('/articles/[slug]')throws unless you writerevalidatePath('/articles/[slug]', 'page'). Thetypeis required whenever the path contains a[segment]; a literal path like/articles/my-postdoes not need it. - Calling
revalidatePath('/articles')expecting it to refresh/articles/foo. Apagerevalidation does not cascade to children. Use'layout'(which does invalidate nested pages) or a sharedrevalidateTag. - Calling
updateTagfrom the webhook route. It is Server-Actions-only and throwsupdateTag can only be called from within a Server Action. UserevalidateTagin route handlers; reserveupdateTagfor in-app publish actions that need read-your-own-writes. - Tagging data with the literal string
'posts'but callingrevalidateTag('post'). Tags are case-sensitive and typos silently no-op. - Expecting an instant refresh from a route handler. In a webhook,
revalidatePathonly marks the path stale; the rebuild happens on the next visit, not at the moment of the call. Hit the page once (or warm it) to confirm. - Triggering revalidation in a loop from a webhook that fires on every keystroke (autosave). Rate-limit or debounce.
- Not testing rollback: after a bad publish, can you re-revalidate from the previous CMS state? Worth rehearsing.
FAQ
- When should I use
updateTaginstead ofrevalidateTag?: UseupdateTagonly inside a Server Action when the same user needs to see their own change immediately (read-your-own-writes), for example right after submitting a form. It expires the cache and makes the next request wait for fresh data. For a webhook or any route handler, you cannot use it; userevalidateTag(tag, 'max'), which keeps serving the old page while the new one renders. - Does on-demand revalidation work on Cloudflare or Netlify?: Yes, increasingly so. Next.js 16.2 shipped a stable Adapter API built with OpenNext, Netlify, and Cloudflare, so platforms no longer reverse-engineer the build. Netlify’s adapter supports on-demand and time-based revalidation; on Cloudflare, use the OpenNext adapter (the older
next-on-pagesis deprecated). Test your exact setup before you rely on it. - What is the difference between
revalidatePathandrouter.refresh()?:revalidatePathinvalidates the server cache for everyone.router.refresh()re-fetches data in the current client only and clears nothing server-side. - Can I revalidate from a Server Action instead of a route handler?: Yes, and it is cleaner if the publish action is in-app. Reserve the webhook route for external triggers (a CMS, a GitHub commit).
- What about the Pages Router?:
res.revalidate('/path')in an API route. Same concept, different API. Thenext/cachefunctions in this article are App Router only. - Will revalidation refresh metadata (title, OG image)?: Yes. Metadata is part of the route render, so the first request after revalidation gets fresh metadata along with the page.
For the canonical behavior and parameter rules, keep the official references handy: revalidatePath and revalidateTag.
Related
- Next.js App Router concepts
- Next.js Content-Site SEO: The Things That Bite
- Vercel ISR vs SSG for Content Sites: Which Wins
- Next.js MDX Bundler vs Contentlayer for Content Sites
Tags: #Indie dev #Next.js #isr #Content #Workflow