给 Next.js 16 上 ISR,我踩了三个坑:connection、mock 污染与构建失败
作者:陈宁(万象部落) 关键词:Next.js、ISR、SSG、缓存、revalidate、connection 分类:架构
背景
在上篇《从 TP5.1 到 Next.js BFF:7 个让首页一直显示 Mock 的坑》里,我把存量 ThinkPHP 博客接到了 Next.js 16 的 BFF 架构上。当时的做法是:内容页全部 export const dynamic = "force-dynamic",每次请求都实时去 BFF(Next.js 自身)→ web1(ThinkPHP)→ MySQL 取数据。
这样能保证数据最新,但代价也明显:
- 每次访问都穿透到 MySQL,文章这种低频变化的内容完全没必要实时;
- 服务端渲染延迟随后端响应波动;
- 高并发下后端压力大。
所以很自然地想上 ISR(Incremental Static Regeneration):首次生成后缓存 HTML,后台按 revalidate 周期刷新。听起来很简单,但我在 Next.js 16 上踩了三个坑,每个都花了不少时间。这篇文章完整记录,希望能帮你少走弯路。
架构回顾
浏览器 → slb Nginx:443 → node:3000(Next.js BFF) → web1:80(TP5.1,后续 Gin)
内容页数据链路:
坑 1:connection() 让 ISR 静默失效
现象
我给首页加上了:
export const revalidate = 600; // 10 分钟
并在页面组件里调用 connection() 来「构建期跳过预渲染」,理由是防止构建期在无后端的 CI 里把 mock 烤进产物:
import { connection } from "next/server";
export default async function Home() {
await connection(); // 我原以为这只是让预渲染停下
const data = await getHome(600);
// ...
}
结果部署后 curl -I 一看:
cache-control: private, no-cache, no-store, max-age=0, must-revalidate
完全没有 x-nextjs-cache 头,也没有 s-maxage。 ISR 根本没生效。
根因
查了 Next.js 16 官方文档,关键在 fetchCache 的默认行为:
By default, Next.js will cache any
fetch()requests that are reachable before any Request-time APIs are used and will not cachefetchrequests that are discovered after Request-time APIs are used.
connection() 是一个 Request-time API。它之后的 fetch 请求都不会被缓存,整条路由退化成纯动态渲染。我之前还纠结「为什么文档里 connection 用于跳过预渲染却不影响 revalidate」——实际上在 cacheComponents 关闭的默认模型下,connection() 之后的 fetch 直接不缓存,ISR 就废了。
修复
去掉 connection()。 那「构建期防 mock」怎么办?这正是坑 2 和坑 3 要解决的问题。
Checklist
-
connection()/cookies()/headers()等 Request-time API 之后的 fetch 不会被缓存 - 用了这些 API,
revalidate就形同虚设,页面对外是no-store - 想让 ISR 生效,页面里不要出现 Request-time API
坑 2:构建期 mock 污染——老问题换个马甲
现象
去掉 connection() 后,revalidate 确实生效了,但出现了一个更隐蔽的问题:构建产物里是 mock 数据。
原因很简单:CI 构建环境里没有 BFF/web1,fetchApi 到 http://localhost:3000 必然失败,而 lib/blog.ts 里每个取数函数都有 try/catch 回退到本地 mock。于是:
- 构建期预渲染时 fetch 失败 → 回退 mock → mock 被烤进
.next静态产物 - 线上首次访问直接命中这个带 mock 的产物
这不就是上篇文章里「坑 4:构建期静态预渲染把 mock 烤进产物」吗?只是这次换成了 ISR 的形态。
修复思路
其实我在坑 1 里就隐约想要一个「构建期不取数」的方案,但 connection() 会连带把缓存也关掉。后来意识到真正的解法是另一个维度:构建期让 fetch 走到一个真实可达的地址。
CI(GitHub Actions)是有公网访问的。于是:
const isBuildPhase = process.env.NEXT_PHASE === "phase-production-build";
const API_BASE = isBuildPhase
? process.env.NEXT_PUBLIC_API_BASE ?? "https://www.wxbuluo.com" // CI 走公网,真实数据
: isServer
? process.env.API_BASE_URL ?? "http://localhost:3000" // 运行时走内网 BFF
: process.env.NEXT_PUBLIC_API_BASE ?? "";
关键点:
- 构建期(
NEXT_PHASE === "phase-production-build"):用公网https://www.wxbuluo.com,CI 能访问,拉到的是真实数据; - 运行时(node 容器):用内网
localhost:3000(BFF),不依赖公网回环。
这样构建产物里是真实文章而不是 mock。
Checklist
- ISR 页面在构建期会真实执行取数,取不到就回退(mock/空),必须让构建期能取到真数据
- CI 环境通常有公网,可用公网 URL 做构建期取数
- 运行时无外网回环时,走内网 BFF 地址,两者用
NEXT_PHASE区分
坑 3:数据层抛错会让 next build 直接失败
现象
在坑 2 的早期版本,我想用「构建期守卫」——数据层在构建期抛错,让 Next 把该路由标记为动态而不是烤 mock:
function isrGuard(revalidate?: number) {
if (isBuildPhase && revalidate != null) {
throw new Error("[ISR] build-time fetch unavailable");
}
}
结果构建直接失败:
Error occurred prerendering page "/". Read more: https://nextjs.org/docs/messages/prerender-error
Error: [ISR] build-time fetch unavailable
at e (lib/blog.ts:8:11)
Export encountered an error on /page: /, exiting the build.
根因
我对 Next 的容错假设错了。带 revalidate 的路由在构建期预渲染失败时,并不会「自动降级为动态」然后继续构建——它会直接 exiting the build。Next 16 的取舍是:宁可构建失败,也不能让一个静态化路由带着错误产物上线。
所以「在数据层抛错来阻止预渲染」这条路走不通。
修复
不抛错。用坑 2 的公网取数方案,让构建期成功拿到真实数据,预渲染自然成功。如果公网也失败(罕见),就让 catch 兜底——但那已经是极小概率,且不会阻塞构建。
Checklist
-
revalidate路由构建期预渲染抛错 →next build直接失败,不会降级 - 不要在数据层用「抛错」来阻止 ISR 预渲染
- 正确姿势是让构建期取数成功(如公网 URL),而不是让预渲染失败
正确的 ISR 姿势(最终版)
经过三个坑,最终方案是:
1. fetchApi 支持 revalidate
export async function fetchApi<T>(path: string, opts?: { revalidate?: number }) {
const cache: RequestCache | undefined = opts?.revalidate != null ? undefined : "no-store";
const next = opts?.revalidate != null ? { revalidate: opts.revalidate } : undefined;
const res = await fetch(url, { headers: { Accept: "application/json" }, cache, next });
// ...
}
- 提供
revalidate→ 用next: { revalidate },进入 ISR 缓存; - 不提供(搜索等实时请求)→ 保持
no-store。
2. 页面声明 revalidate
// 首页:10 分钟
export const revalidate = 600;
// 文章详情:1 小时
export const revalidate = 3600;
3. 构建期用公网、运行时用内网
const API_BASE = isBuildPhase
? process.env.NEXT_PUBLIC_API_BASE ?? "https://www.wxbuluo.com"
: isServer ? process.env.API_BASE_URL ?? "http://localhost:3000"
: process.env.NEXT_PUBLIC_API_BASE ?? "";
4. 动态路由用真实 generateStaticParams
// app/article/[id]/page.tsx
export async function generateStaticParams() {
const ids = await getArticleIds(); // 构建期拉真实 70 篇文章 id
return ids.map((id) => ({ id: String(id) }));
}
构建输出变成:
Route (app) Revalidate Expire
┌ ○ / 10m 1y
├ /article/[id]
│ ├ ● /article/139 1h 1y
│ ├ ● /article/138 1h 1y
│ ├ ● /article/137 1h 1y
│ └ ● [+67 more paths]
5. 验证
$ curl -sI https://www.wxbuluo.com/
x-nextjs-cache: HIT
cache-control: s-maxage=600, stale-while-revalidate=31535400
$ curl -sI https://www.wxbuluo.com/article/139
x-nextjs-cache: HIT
cache-control: s-maxage=3600, stale-while-revalidate=31532400
x-nextjs-cache: HIT→ 命中缓存;s-maxage=600 / 3600→ 首页 10 分钟、文章 1 小时;stale-while-revalidate→ 过期后先返回旧缓存,后台刷新,用户体验无感。
为什么不用 connection()?再补一句
有同学会问:「文档里说 connection() 能让预渲染停止,构建期不预渲染,那我不就能避免 mock 了吗?」
是的,connection() 能阻止预渲染,但它同时会让后续 fetch 不缓存,从而让 revalidate 完全失效。所以在「默认缓存模型」(未开启 cacheComponents)下,connection() 和 ISR 是互斥的。
如果你确实需要「某些内容请求时才渲染,但整体要缓存」,Next 16 的推荐路径是 Cache Components(cacheComponents + use cache),这是另一套体系。我这套存量代码没开 Cache Components,所以用了公网取数的方案,更贴合现有架构。
总结
三个坑一句话概括:
connection()让 ISR 失效——Request-time API 之后的 fetch 不缓存,删掉它;- 构建期 mock 污染——构建期 fetch 到不存在的地址会回退 mock 烤进产物,让构建期走公网取真实数据;
- 数据层抛错会杀构建——
revalidate路由预渲染失败直接exiting the build,别用抛错阻止预渲染。
最终这套组合拳让首页、文章、分类、标签页全部获得 ISR:构建产物是真实数据、运行时命中缓存、后台按时刷新,后端压力大幅下降,首屏延迟也随之降低。
希望这篇记录能帮你绕开这些坑。