Next.js On-Demand Revalidation: Webhook Setup (2026)

Editors should not wait for a redeploy to fix a typo. Wire on-demand revalidation in Next.js 16 App Router: webhook route, header secret, revalidatePath vs revalidateTag vs updateTag, and how to prove it works.

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/revalidate route handler, guarded by a header secret, that calls revalidatePath or revalidateTag based 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. updateTag is Server-Actions-only and is the right tool for in-app “read-your-own-writes” publishing.
  • Prove it with three curl checks: 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.

FunctionWhere it runsScopeBehavior
revalidatePath(path, type?)Server Functions + Route HandlersOne page/layout pathMarks the path stale; refreshes on next visit
revalidateTag(tag, profile?)Server Functions + Route HandlersEvery cache entry with that tagprofile: 'max' = stale-while-revalidate; no profile = legacy immediate-expire
updateTag(tag)Server Actions onlyEvery cache entry with that tagExpires immediately; next request waits for fresh data (read-your-own-writes)
cacheTag(tag)Inside a 'use cache' functionTags that cache entryDeclares 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 = 60 on 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 type keeps the bundle clean.
  • Header secret over query param. A header does not show up in CDN access logs.
  • No trailing slash on the path. revalidatePath matches the route file structure, not the URL bar, so /en/articles/my-slug is correct regardless of your trailingSlash config. For a literal path (no [slug] segment) you omit the second type argument.
  • Bilingual paths. Pass lang so 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 HTTP Referer headers on outbound links. Use a header.
  • Passing a dynamic route pattern without the type argument. revalidatePath('/articles/[slug]') throws unless you write revalidatePath('/articles/[slug]', 'page'). The type is required whenever the path contains a [segment]; a literal path like /articles/my-post does not need it.
  • Calling revalidatePath('/articles') expecting it to refresh /articles/foo. A page revalidation does not cascade to children. Use 'layout' (which does invalidate nested pages) or a shared revalidateTag.
  • Calling updateTag from the webhook route. It is Server-Actions-only and throws updateTag can only be called from within a Server Action. Use revalidateTag in route handlers; reserve updateTag for in-app publish actions that need read-your-own-writes.
  • Tagging data with the literal string 'posts' but calling revalidateTag('post'). Tags are case-sensitive and typos silently no-op.
  • Expecting an instant refresh from a route handler. In a webhook, revalidatePath only 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 updateTag instead of revalidateTag?: Use updateTag only 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; use revalidateTag(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-pages is deprecated). Test your exact setup before you rely on it.
  • What is the difference between revalidatePath and router.refresh()?: revalidatePath invalidates 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. The next/cache functions 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.

Tags: #Indie dev #Next.js #isr #Content #Workflow