Next.js 按需重新验证:Webhook 接法(2026)

编辑改个错字不该等重新部署。本文讲清楚 Next.js 16 App Router 怎么接 on-demand revalidation:webhook 路由、header 密钥、revalidatePath / revalidateTag / updateTag 怎么选、以及怎么验证真生效。

On-demand revalidation 是把”静态 + ISR”用出 CMS 手感的关键功能。编辑在 Sanity 里点发布、或者推一个 Markdown commit,webhook 打到 Next.js 应用上,受影响的页面几秒内刷新,不用重新部署,也不用全量重建。用过之后,再为一个错字跑全量部署就觉得很离谱。本文按 Next.js 16(2025 年 10 月发布,下面这些缓存 API 在 16.2 转正,截至 2026 年 6 月为当前版本)把这一套接对。

一句话总结

  • 加一条 POST /api/revalidate 路由,用 header 密钥保护,按 webhook 传来的内容调 revalidatePathrevalidateTag
  • 单篇用 revalidatePath('/zh/articles/[slug]');跨页变化(新增一篇要顺带刷列表页和首页)用 revalidateTag('articles', 'max')
  • 在 webhook(route handler)里只能用 revalidateTag / revalidatePathupdateTag 只能在 Server Action 里用,专治站内发布要”立刻看到自己刚写的”那种场景。
  • 用三条 curl 验证:带密钥通过、不带密钥被拒、页面内容确实变了。

背景:四个原语

Next.js 16 的 next/cache 暴露四个失效函数。截至 2026 年 6 月(Next.js 16.2),use cache 这套缓存 API 已转正,unstable_ 前缀都去掉了。

函数在哪能用作用范围行为
revalidatePath(path, type?)Server Function + Route Handler单个 page/layout 路径标记该路径为过期,下次访问时刷新
revalidateTag(tag, profile?)Server Function + Route Handler带该 tag 的所有缓存项profile: 'max' = stale-while-revalidate;不传 profile 走旧的立即过期行为
updateTag(tag)仅 Server Action带该 tag 的所有缓存项立即过期,下次请求等新数据(read-your-own-writes)
cacheTag(tag)'use cache' 函数内部给该缓存项打 tag声明上面两个要失效的 tag

CMS webhook 是外部触发,所以它落在 route handler 里,用 revalidatePath / revalidateTagupdateTag 在这里调不了,会抛 updateTag can only be called from within a Server Action。宿主需要持久化函数缓存:Vercel 自带;Next.js 16.2 的稳定 Adapter API 让 Netlify 和 Cloudflare(走 OpenNext 适配器)也能支持按需 revalidation。

怎么判断你需要它

  • 编辑抱怨更新要 15 分钟才能看到(你的完整部署时长)。
  • 每页都写了 export const 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 { 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' 走 stale-while-revalidate:新页面后台渲染时,
    // 访客继续看到旧页面,不会卡住。
    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 });
}

四个要点:

  • NextRequestimport type Next.js 16 / TypeScript 会标记只当类型用的值导入;import type 让产物更干净。
  • 密钥放 header 而不是 query。 header 不会出现在 CDN 访问日志里。
  • 路径不带末尾斜杠。 revalidatePath 匹配的是路由文件结构,不是地址栏里的 URL,所以无论 trailingSlash 怎么配,/en/articles/my-slug 都是对的。字面路径(不含 [slug] 段)时第二个 type 参数可以省。
  • 双语路径。lang 让 EN 和 ZH 独立 revalidate,省掉全站刷新。

给缓存数据打 tag,revalidateTag 才有用

revalidateTag 只对打了对应 tag 的数据生效。Next.js 16 里有两种打 tag 的方式,都能用。

如果是从外部 CMS API 拉数据,就给 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();
}

如果用已转正的 'use cache' 指令缓存整个函数(数据库查询、读 Markdown),就用 cacheTag 打 tag:

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

两种方式下,revalidateTag('article:my-slug', 'max') 只刷新那一篇的数据;revalidateTag('articles', 'max') 刷新列表页和所有读了 articles tag 的文章。

配 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。用 header。
  • 动态路由模式没带 type 参数。revalidatePath('/articles/[slug]') 会抛错,得写成 revalidatePath('/articles/[slug]', 'page')。只要路径里有 [段] 就必须带 type;字面路径如 /articles/my-post 则不用。
  • revalidatePath('/articles') 指望它刷新 /articles/foopage 级 revalidation 不会级联到子页。要么用 'layout'(它会让嵌套子页一起失效),要么用共享 revalidateTag
  • 在 webhook 路由里调 updateTag。它只能在 Server Action 里用,会抛 updateTag can only be called from within a Server Action。route handler 里用 revalidateTagupdateTag 留给需要”立刻看到自己刚写的”的站内发布动作。
  • 数据上打的是 'posts'revalidateTag('post') 调成了单数。tag 区分大小写,typo 不报错,静默失效。
  • 指望从 route handler 立即刷新。在 webhook 里 revalidatePath 只是把路径标记为过期,重建发生在下次访问,而不是调用那一刻。访问一次页面(或预热)才能确认。
  • 自动保存(每敲一下键)触发 webhook 进入死循环。加限流或 debounce。
  • 没测试回滚:发错版本后能不能再用 CMS 的上一版状态触发一次 revalidate?值得演练。

FAQ

  • 什么时候该用 updateTag 而不是 revalidateTag: 只在 Server Action 里、且同一个用户需要立刻看到自己刚做的改动(read-your-own-writes)时用,比如提交表单后立即回显。它会让缓存过期、下次请求等新数据。webhook 或任何 route handler 里都用不了它,得用 revalidateTag(tag, 'max')——它在新页面渲染时继续给旧页面。
  • Cloudflare 或 Netlify 上能用 on-demand revalidation 吗?: 越来越能。Next.js 16.2 联合 OpenNext、Netlify、Cloudflare 推出了稳定的 Adapter API,平台不用再逆向构建产物。Netlify 适配器支持按需和定时 revalidation;Cloudflare 上用 OpenNext 适配器(旧的 next-on-pages 已弃用)。依赖前先在自己的环境里测一遍。
  • revalidatePathrouter.refresh() 区别?: revalidatePath 让所有用户的服务端缓存失效。router.refresh() 只让当前客户端重新拉数据,服务端什么都不清。
  • 能在 Server Action 里 revalidate 吗?: 能,而且站内发布动作放 Server Action 更干净。webhook 路由留给外部触发(CMS、GitHub commit)。
  • Pages Router 怎么做?: API 路由里 res.revalidate('/path')。概念一样,API 不同。本文这些 next/cache 函数只用于 App Router。
  • revalidate 会刷新 metadata(title、OG 图)吗?: 会。metadata 是路由渲染的一部分,revalidate 后的第一次请求会连着页面一起拿到新的 metadata。

官方参考留着备查:revalidatePathrevalidateTag

相关阅读

标签: #独立开发 #Next.js #isr #内容 #工作流