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 = 60on 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
langso 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 orrevalidateTag. - Tagging fetches with the literal string
'posts'but callingrevalidateTag('post')— typos silently no-op. - Forgetting that
revalidatePathis 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
revalidatePathandrouter.refresh()?:revalidatePathinvalidates 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.
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