Next.js ISR 重新验证卡住、一直返回旧页面(Vercel, 2026)

ISR 页面 revalidate 窗口过了还在发旧 HTML。先读 x-vercel-cache 响应头、手动触发 on-demand 重验,再定位真正原因:边缘缓存盖住 ISR、后台渲染失败,或路径不匹配。

14:00 改了内容上线,页面配的是 revalidate: 60,预期 14:01 看到新标题。结果 14:30 还是旧的。给 URL 加个 ?t=${Date.now()} 又是新的,访问正常 URL 又变旧。

最快的判断: 跑一句 curl -sI "https://your-site.com/path",读 x-vercel-cache 响应头。如果是 STALE,说明 ISR 在正常工作 —— 后台渲染完成后下一次请求就是新的,多刷两次正常 URL 即可。如果是 HITage 比你的 revalidate 大,那是边缘 CDN(不是 ISR)在持有旧页面,需要用 revalidatePath 强制重验(见步骤 2)。一次 refetch 基本就能分清自己属于哪一类。

这几乎从来不是 ISR “坏了”。截至 2026 年 6 月,真正的原因是:页面正在后台重生成、你太早读到了那个本就预期内的 STALE 响应;边缘或下游 CDN 用过长的 s-maxage 盖住了 ISR;半失败的部署留下错位的 prerender-manifest.jsonrevalidatePath 调用了一个不再匹配路由的路径;或者数据 fetch 在后台 revalidate 时抛了异常,之后 Next.js 默默继续发旧页面,且不会自动重试。

第一步:读懂 x-vercel-cache 响应头

后面所有判断都依赖这一个值。Vercel CDN 会在每个响应上设 x-vercel-cache,它的官方含义(Vercel response-headers 文档)直接告诉你你在和哪一层缓存较劲:

含义卡住了吗?
HIT从 CDN 返回,内容仍然新鲜。只有当 age 超过你的 revalidate 才有问题 —— 那是边缘 TTL 太长。
STALE从缓存返回,但已过期,于是触发了一次后台重生成。没卡。这是健康的 ISR。重生成完成后的下一次请求就是新的。
MISS缓存里没有,从源站取。动态 / runtime-cache 路由很正常。一般没卡。动态路由即便数据来自 runtime cache,也照样显示 MISS
PRERENDER从静态存储返回(如 fallback: true)。没卡。
REVALIDATED缓存被删除(不带 lifetime 的 revalidatePath/revalidateTag),前台重新渲染。没卡 —— 这是 on-demand 清除之后紧接着的那一次响应。
BYPASS完全跳过缓存。路由实际上是动态的,查一下为什么。

最常见的误判:上线一个改动,刷一次,看到 STALE,就断定 ISR 坏了。它没坏。STALE 表示后台重生成已经在跑了;再隔一两秒刷一两次,就该拿到新页面(随后变 HIT)。真正卡住只有两种情况:(a) HITage 远超 revalidate;(b) STALE 一直不变新,因为后台渲染反复失败。

关于 x-nextjs-cache:那是 Pages Router 时代的信号。现代 Vercel 部署上,边缘状态的权威来源是 x-vercel-cache,优先信它。

常见原因

按 Vercel + Next.js 13/14/15 上的实际频率排序。

1. CDN 边缘缓存命中盖住了 ISR 层

Vercel 边缘缓存位于 Next.js ISR 之前。如果路由发出 Cache-Control: public, s-maxage=3600(来自 middleware、vercel.json 或手动设的响应头),边缘会按 1 小时保留,无视你 ISR 的 revalidate: 60。Next.js-on-Vercel 的 ISR 默认头是 s-maxage={revalidate}, stale-while-revalidate=31536000;问题几乎总是有东西用更大的 s-maxage 盖掉了 这个默认值。

响应头优先级很关键:Vercel-CDN-Cache-Control 是 Vercel 专属、优先级最高,压过 CDN-Cache-Control,再压过普通 Cache-Control。如果你只设了 Cache-Control 而没设 CDN 专属头,Vercel 会在发给浏览器前把 s-maxagestale-while-revalidate 剥掉,所以光在浏览器里看不出问题 —— 要在服务端用 curl 查。

下游 CDN 是另一个高频元凶(2026 的新场景):如果你在 Vercel 前面套了 Cloudflare 之类的代理,并在那条 ISR 路由上设了 Edge TTL,那个代理会缓存旧 HTML,让 Vercel 的 stale-while-revalidate 周期永远跑不完。规则:ISR 路由绝不要在外部设 Edge TTL,让 Vercel 的 s-maxage/stale-while-revalidate 头原样透传。

如何识别curl -sI https://your-site/path 看到 x-vercel-cache: HITage: 接近 3600。cache-control 里如果 s-maxage 大于 revalidate,赢家就是边缘。

2. on-demand revalidatePath 调错路径

revalidatePath('/blog/[slug]') 不会匹配 /blog/my-post。要么传字面路径 revalidatePath('/blog/my-post'),要么对动态路由传 token 形式加 type:revalidatePath('/blog/[slug]', 'page')(App Router)。当路径含动态段时,第二个参数 type'page' | 'layout')是必填的,只有字面路径才能省略 —— 这是最常见的错。

如何识别:触发调用 revalidatePath 的 webhook 后再请求一次。正确的清除会返回 x-vercel-cache: REVALIDATED(随后是带新内容的 HIT);如果还是 HIT 加旧 HTML,说明路径根本没匹配上。

3. 后台 revalidate 时 getStaticProps / RSC fetch 抛异常

ISR 后台 revalidate 中数据获取抛异常,Next.js 只是默默写日志,继续发旧页面。没有自动重试。除非下一次后台 revalidate 成功或者重新部署,否则一直旧。

如何识别:函数日志反复打印这条页面 fetch 抛异常,但 URL 一直 200 + 旧内容。

4. prerender-manifest.json 在不同部署间错位

部署中途失败会留下指向上一次构建 HTML 的 prerender manifest。新 revalidate 写到新位置但读还是走旧的。失败但被强制 promote 的部署最容易出这种问题。

如何识别:同一 URL 在不同边缘节点(用 curl --resolve 切换 IP)返回不同 HTML,说明 manifest 不同步。

5. 路由根本没进 prerender manifest(cookies / headers / 动态 API)

revalidate 要生效,路由必须在构建时被登记进 Vercel 的 prerender manifest。如果某个 server component 调了 cookies()headers() 或读了 searchParams,Next.js 会把这条路由从静态渲染里踢出去,于是它一直是全动态的,你的 export const revalidate 就成了空操作。你以为是 ISR,其实是 dynamic-but-cached,重验语义完全不同。

如何识别:构建日志这条路由前面是 (λ) / (ƒ) / (d),不是 (SSG) / (ISR) / (○)。Vercel “Functions” 标签里能看到这条路由大部分请求都有调用量,而且即使不在任何 revalidate 窗口内,x-vercel-cache 也读 MISS

6. webhook 打到了错误的区域 / 部署

revalidatePath 只对收到调用的那个部署生效。如果 webhook 指向 your-site.vercel.app 但生产域名其实别名指向另一个部署,那 invalidation 落在没人看的部署上。

如何识别:webhook 返回 200 OK,但生产页面还是旧的。检查 webhook URL 是不是生产域名,而不是 preview 或 branch 别名。

7. unstable_cache / fetch 的缓存盖过 ISR

App Router 页面里的 fetch 自带缓存层(force-cachenext.revalidate)。两层缓存不一致时,时间长的赢。

如何识别:页面级 revalidate = 60,但 fetch(..., { next: { revalidate: 3600 } }) 在里面。fetch 那条赢。

开始排查前

  • curl -I 在未登录会话外面确认 staleness(cookies 会把路由踢出 ISR)。
  • 对比时间戳:什么时候上线改动、页面 revalidate 多少、响应 age 多少。
  • 弄清楚是哪个 Router:Pages Router(getStaticProps + revalidate)还是 App Router(export const revalidate / fetch revalidate / revalidateTag)。
  • 有管理员权限可以手动调 revalidatePath,作为强制重验的手段。

需要收集的信息

  • 完整响应头:cache-controlx-vercel-cachex-nextjs-cacheagex-vercel-id
  • 页面的 revalidate 配置以及内部每个 fetch 的缓存配置。
  • 最近 10 分钟该路由的函数日志(盯后台 revalidate 抛的异常)。
  • on-demand revalidate 的 webhook payload + URL,加上响应码。
  • 路由是否用了 cookies()headers()searchParams
  • 生产环境当前别名指向的部署 ID(vercel ls --prod)。

分步修复

按性价比排序。

步骤 1:看响应头定位到底哪一层缓存在发旧

curl -sI "https://your-site.com/blog/my-post"

重点看:

  • x-vercel-cache: STALE → 已过期但正在后台重生成。多 refetch 一两次,下一条响应就该是新的。没卡。
  • x-vercel-cache: HITage 大于 revalidate → 边缘 / CDN 层把它持有过了 ISR 窗口。这才是真正的 bug。
  • x-vercel-cache: MISS → 动态 / runtime-cache 路由(不是经典 ISR)。见原因 5。
  • age: 3500 → 这条响应已经存了 3500 秒,无视你 revalidate
  • cache-control: ...s-maxage=N → 边缘按 N 秒保留。
  • x-vercel-id: ... → 请求命中的区域,只有部分区域旧时很有用。

如果一直看到 STALE,就隔一两秒多打几次。要是始终变不新,说明后台渲染在失败 —— 跳到步骤 4。

步骤 2:用 on-demand API 强制重新验证

写一个 route handler:

// app/api/revalidate/route.ts
import { revalidatePath } from "next/cache";
import { NextRequest, NextResponse } from "next/server";

export async function POST(req: NextRequest) {
  const secret = req.nextUrl.searchParams.get("secret");
  if (secret !== process.env.REVALIDATE_SECRET) {
    return NextResponse.json({ ok: false }, { status: 401 });
  }
  const path = req.nextUrl.searchParams.get("path");
  if (!path) return NextResponse.json({ ok: false }, { status: 400 });
  revalidatePath(path);
  return NextResponse.json({ ok: true, revalidated: path });
}

用字面路径调用:

curl -X POST "https://your-site.com/api/revalidate?secret=$REVALIDATE_SECRET&path=/blog/my-post"

再 fetch 一次,如果变新了,说明 on-demand 是通的,把它正确接进 CMS webhook 即可。

步骤 3:让 s-maxage 不大于 revalidate

App Router 页面不要手动设 Cache-Control,Next.js 自己设是对的。如果某个 middleware 或响应头覆盖了它:

// middleware.ts — 删掉或改正类似这种行:
// response.headers.set("Cache-Control", "public, s-maxage=3600, ...");

如果确实要自定义,至少要让 s-maxagerevalidate 对齐:

response.headers.set(
  "Cache-Control",
  `public, s-maxage=60, stale-while-revalidate=86400`,
);

步骤 4:盯后台 revalidate 的抛错

触发 revalidate 时同步看实时 runtime 日志。截至 2026 年 6 月,CLI 用 --follow(简写 -f)做实时流式输出,最多跑 5 分钟:

vercel logs --follow
# 或锁定某个部署:
vercel logs --follow --deployment dpl_xxxxx

revalidate 窗口附近有 stacktrace,就说明数据 fetch 在抛。Next.js 不重试也不告警,会一直发旧页面(也就是”STALE 永远变不新”那种情况)。对应服务端错误类型见 vercel 500 errors

步骤 5:确认 App Router 的 fetch 缓存没盖过页面级

审一遍页面里的每个 fetch(...)

// 顶层数据 fetch 都用页面级 revalidate
const res = await fetch(url, { next: { revalidate: 60 } });

或者用 revalidateTag 按逻辑分组:

const res = await fetch(url, { next: { tags: ["post-list"], revalidate: 3600 } });
// 别处:
import { revalidateTag } from "next/cache";
revalidateTag("post-list");

步骤 6:确认 webhook 打的是生产部署

vercel ls --prod

确认 webhook URL 是生产域名 your-site.com,不是 preview 别名 your-site-git-main-team.vercel.app。invalidation 是按部署来的,打错就静默无效。

步骤 7:强制清边缘缓存(兜底)

实在要立刻见效:

  • 改一行无关代码触发重新构建 —— 新构建会拿到新的边缘缓存。
  • 或在面板里清:Project → Settings → Caches → 按 tag invalidate(或 delete)。这是官方支持的 on-demand 清除路径,对应上面那张表里的 STALE/REVALIDATED 状态。
  • 要回退到某个干净的已知好部署,就重新 promote:Project → Deployments → 该部署的菜单 → Promote to Production

别拿这一招当日常做法,它只是盖住了真正的修复。

怎么确认修好了

  • 对路径 curl -sI 返回新内容且 x-vercel-cache: HIT(之前的 STALE 在请求后几秒内稳定翻成 HIT)。
  • POST 到 /api/revalidate?path=... 后第一次 refetch 返回 x-vercel-cache: REVALIDATED,随后 2-3 秒内拿到新内容。
  • 函数日志显示后台 revalidate 都是 200 且窗口内没有抛异常。
  • CMS 编辑 + webhook 测试在配置的 revalidate 窗口内传到线上 —— 在干净的未登录会话里验证,因为 cookies 会把路由踢出 ISR。

长期预防

  • 全站 s-maxage 始终 ≤ revalidate,边缘 TTL 不许超过 ISR 窗口。
  • 所有数据 fetch 用 try/catch 兜住、返回 sentinel 而不是抛异常,避免后台 revalidate 静默失败。
  • revalidateTag"posts""site-config")做逻辑分组重验,比一条一条 path 强。
  • 在 CI 里测重验 webhook:部署、改测试数据、调 webhook、断言 HTML 已更新。
  • 每次 revalidatePath / revalidateTag 调用都记录 path 和结果便于回溯。
  • 全站统一 Pages Router 或 App Router,混用相当于跑两套缓存模型,互相影响很难调。

常见坑

  • revalidatePath('/blog/[slug]') 期望刷新所有 blog 文章 —— App Router 不带第二个参数 'page' 时它只匹配字面字符串。
  • “为安全”设 revalidate = 0 —— 这等于把路由从静态生成里踢出去,每次请求都付函数调用代价。
  • 忘了 server component 里用 cookies()headers() 会让整条路由退出 ISR,把这些挪到 Route Handler 里。
  • 以为改了 layout、util 之类的非内容代码会触发 ISR —— 它们只在部署时生效,revalidate 阶段不会重读。
  • 低流量页面期望 ISR 按时刷新 —— ISR 是请求驱动的,没流量就不会 revalidate。相关”静态站像没更新”模式见 deploy succeeded page old

常见问答

Q: curl 显示 x-vercel-cache: STALE,我的 ISR 是不是坏了?

没坏 —— STALE 是健康状态。它表示缓存副本过期了,Vercel 已经发起了一次后台重生成。隔一两秒 refetch 一两次,就该拿到新 HTML,之后响应头变成 HIT。只有这两种情况才是真出问题:STALE 一直变不新(后台渲染在失败 —— 查日志),或者 HITage 远超你的 revalidate(边缘 TTL 太长)。

Q: 我的页面 revalidate: 60,但日志里完全看不到后台 revalidate?

ISR 后台 revalidate 只在窗口过后下一次请求来时触发。从第一分钟起就没流量的页面会一直缓存。关键低流量页面建议定时发合成 ping(用 cron 去打那个 URL),让窗口里有请求来触发。

Q: 怎么一次性 invalidate 整站?

revalidatePath('/', 'layout') 会 invalidate 根 layout 实际上等于所有子页面。慎用,会触发一波集中重生成。

Q: 部署完了页面还是旧的,新构建也没用。

新构建会重置 prerender manifest,但 CDN 仍可能按 s-maxage 持有旧响应。等一个 s-maxage 窗口或者用 Vercel REST API 清。

Q: 要不要干脆切到全动态?

只有当 revalidate 模型从根本上不能覆盖你的需求(按用户、按区域)时才切。全动态在计算量和 TTFB 上是 10-100 倍代价。先把 ISR 修通。

标签: #排查 #Next.js #isr #cache #Vercel