Next.js 内容更新按需重新验证(On-Demand Revalidation)

编辑改个错字不该等重新部署。本文讲清楚 App Router 怎么接 on-demand revalidation——webhook、密钥、按 path 还是 tag、怎么验证生效。

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 不一样。依赖前先测。
  • revalidatePathrouter.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。

相关阅读

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