Next.js On-Demand Revalidation for Content Updates

Editors should not wait for a redeploy to see a typo fix. Here is how to wire on-demand revalidation in App Router — webhook, secret, path vs tag, and how to test 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 — no redeploy, no full rebuild. Once you have it, going back to full redeploys for a typo feels absurd. This article wires it up correctly.

Background

Next.js App Router has two revalidation primitives: revalidatePath('/some/path/') invalidates one URL (or a layout), and revalidateTag('posts') invalidates every cached fetch that was tagged with posts. Both work in route handlers and server actions. The host (Vercel, self-hosted, Cloudflare) needs to support persistent function caches — Vercel does, others vary.

How to tell

  • Editors complain that updates take 15 minutes (your full deploy time) to show up.
  • You see revalidate = 60 on every page and wonder why the CDN hit rate is bad.
  • You have a CMS with a webhook button labeled “Publish” doing 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, revalidateTag for cross-cutting changes (new article triggers home page refresh).

Wire up the route handler

A minimal /api/revalidate route in App Router:

// app/api/revalidate/route.ts
import { revalidatePath, revalidateTag } from 'next/cache';
import { NextRequest, NextResponse } 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) {
    revalidateTag(tag);
    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 });
}

Two design choices worth making explicit:

  • Header secret over query param. A header does not show up in CDN access logs.
  • Bilingual paths. Pass lang so EN and ZH versions are revalidated independently — saves a full-site refresh.

Tag cached fetches so revalidateTag works

revalidateTag only invalidates fetch calls that opted in. Tag your data loads:

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

export async function getAllArticles() {
  const res = await fetch(`${process.env.CMS_URL}/articles`, {
    next: { tags: ['articles'] },
  });
  return res.json();
}

Now revalidateTag('article:my-slug') refreshes only that article’s data; revalidateTag('articles') refreshes the listing page and every article.

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) — leaks into CDN logs, Vercel analytics, and HTTP referer headers on outbound links.
  • Calling revalidatePath('/articles') expecting it to refresh /articles/foo/ — by default it only invalidates the exact path, not children. Use the 'layout' second arg or revalidateTag.
  • Tagging fetches with the literal string 'posts' but calling revalidateTag('post') — typos silently no-op.
  • Forgetting that revalidatePath is per-deployment cache — if you have multiple Vercel deployments serving traffic during a promote, both need the call (Vercel does this; self-hosted may not).
  • Triggering revalidation in a loop from a webhook that fires on every save (autosave) — rate-limit or debounce.
  • Not testing rollback: after a bad publish, can you re-revalidate from the previous CMS state? Worth rehearsing.

FAQ

  • Does on-demand revalidation work on Cloudflare Pages or Netlify?: Partially. Both support it but the cache implementation differs from Vercel’s. Test before relying 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.
  • Can I revalidate from a server action instead of a route handler?: Yes, and it is cleaner if the publish action is in-app. Use a webhook route only for external triggers (CMS, GitHub commit).
  • What about Pages Router?: res.revalidate('/path') in an API route. Same concept, different API.
  • Will revalidation refresh metadata (title, OG image)?: Yes — metadata is part of the route render, so the next request after revalidation gets fresh metadata.

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