On-demand revalidation 是把”静态 + ISR”用出 CMS 手感的关键功能。编辑在 Sanity 里点发布、或者推一个 markdown commit,webhook 打到 Next.js 应用上,受影响的页面几秒内刷新——不用重新部署,不用全量重建。用过之后,再为一个错字跑全量部署就觉得很离谱。本文把这一套接对。
问题背景
Next.js App Router 有两个 revalidation 原语:revalidatePath('/some/path/') 让一个 URL(或 layout)失效,revalidateTag('posts') 让所有打了 posts tag 的 fetch 缓存失效。两个都能在 route handler 和 server action 里用。前提是宿主(Vercel、自托管、Cloudflare)支持持久化函数缓存——Vercel 支持,其他不一定。
判断标准
- 编辑抱怨更新要 15 分钟才能看到(你的完整部署时长)。
- 每页都设了
revalidate = 60,CDN 命中率却很差。 - CMS 里有个”发布”按钮的 webhook,但根本没做事。
- 5000 篇文章的站,改一篇就重新构建全部——build minutes 烧光。
快速结论
任何有 CMS 或定期更新的内容站,都该接一条带共享密钥的 on-demand revalidation route handler。单篇用 revalidatePath,跨页变化(新增文章触发首页刷新)用 revalidateTag。
接 route handler
App Router 下最小可用的 /api/revalidate:
// 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 });
}
两个值得点明的设计选择:
- 密钥放 header 而不是 query。 header 不会出现在 CDN 访问日志里。
- 双语路径。 传
lang让 EN 和 ZH 独立 revalidate,省掉全站刷新。
给 fetch 打 tag,revalidateTag 才有用
revalidateTag 只对加过 tag 的 fetch 生效。数据加载时打 tag:
// 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();
}
这样 revalidateTag('article:my-slug') 只刷新那一篇的数据;revalidateTag('articles') 刷新列表页和所有文章。
配 CMS webhook
Sanity / Contentful / Strapi / Storyblok 都是同一套:
URL: https://yourdomain.com/api/revalidate
Method: POST
Headers: x-revalidate-secret: <env REVALIDATE_SECRET>
Body: { "slug": "{{slug}}", "lang": "{{lang}}" }
大多数 CMS 在 webhook 模板里能取到 {{slug}} 和语言变量。取不到就传文档 ID,在 route handler 服务端查 slug。
端到端测一遍
宣告完成前三件事要测:
# 1. 带正确密钥能用
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. 没密钥被拒
curl -sX POST https://yourdomain.com/api/revalidate \
-H "content-type: application/json" \
-d '{"slug":"my-test-article"}'
# {"ok":false,"error":"unauthorized"}
# 3. 页面确实更新了
curl -s https://yourdomain.com/en/articles/my-test-article/ | grep "key phrase from edit"
容易踩的坑
- 把密钥写在 URL 里(
/api/revalidate?secret=xxx)——会泄漏到 CDN 日志、Vercel analytics、外链的 HTTP referer。 - 调
revalidatePath('/articles')指望它刷新/articles/foo/——默认只让精确路径失效,不递归子节点。要么用第二个参数'layout',要么用revalidateTag。 - fetch 上加的是
'posts',revalidateTag('post')调成了单数——typo 不会报错,静默失效。 - 忘了
revalidatePath是按部署的缓存——promote 期间多份部署同时承载流量时,两份都要被调到(Vercel 自己处理;自托管不一定)。 - 自动保存触发 webhook 进入死循环——加限流或 debounce。
- 没测试回滚:发错版本后能不能再用 CMS 的上一版状态触发一次 revalidate?值得演练。
FAQ
- Cloudflare Pages 或 Netlify 上能用 on-demand revalidation 吗?: 部分能。两家都支持,但缓存实现和 Vercel 不一样。依赖前先测。
revalidatePath和router.refresh()区别?:revalidatePath让所有用户的服务端缓存失效。router.refresh()只让当前客户端重新拉数据。- 能在 server action 里 revalidate 吗?: 能,而且站内发布动作放 server action 更干净。route handler 主要给外部触发(CMS、GitHub commit)用。
- Pages Router 怎么做?: API 路由里
res.revalidate('/path')。概念一样,API 不同。 - revalidate 会刷新 metadata(title、OG 图)吗?: 会——metadata 是路由渲染的一部分,下一次请求拿到的就是新的 metadata。