...
Back

Next.js 跑在 Cloudflare Workers 上:难的不是适配器

我们的生产站点用 OpenNext 跑在 Cloudflare Workers 上。迁移的成本不在适配器,而在这个平台自己的规矩,而且每一条都是以一个具体故障的形式找上门的。

Next.js 跑在 Cloudflare Workers 上:难的不是适配器

Next.js 跑在 Cloudflare Workers 上:难的不是适配器

这周 r/webdev 上有人发了一个问题:Anybody had experience migrating from NextJS to OpenNext / Vinext / Tanstack?

标题里列了三个方向,我们只能回答其中一个:Cloudflare Workers 上的 OpenNext。2026 年 8 月,我们把这个站点从容器平台迁到了 Workers,数据库用 D1,存储用 R2。生产环境现在就跑在上面,而且跑得好好的。

不过这个标题的问法,恰好把最有用的那部分藏起来了。用 OpenNext,你并没有离开 Next.js。你留着 Next.js,只是把它搬到了另一个平台上 —— 而那个平台有它自己的规矩。


难的不是适配器

让适配器把我们的构建产物变成一个 Worker,并不是时间花掉的地方。时间花在了另一件事上:逐条弄清 Workers 对一个 Next.js 应用有哪些要求,而这些要求 Node 服务器从来没提过。换句话说,真正该问的不是"换哪个适配器",而是"新平台的规矩我接不接得住"。

这些要求很少以构建报错的形式出现。它们出现的样子是:一个明明存在的页面返回 404,一个比实际需要大了约六倍的 middleware 包,或者标题和规范链接跑到了 head 外面。下面按类别列出我们撞上的每一条规矩,以及让我们学会它的那个症状。


运行时:要么构建期做完,要么别做

运行时不能生成代码。 我们的 MDX 内容层在渲染时会用 new Function 把每个页面编译成一个函数,而 Workers 禁止这样做。所以每一个 MDX 页面,文档和博客都算在内,都必须在构建期渲染完成。这些路由各自带着三个导出:

export function generateStaticParams() {
  return allPosts.map((post) => ({
    lang: post.lang,
    slug: post.slugAsParams.split("/"),
  }));
}
export const dynamicParams = false;
export const dynamic = "force-static";

这件事的后果很容易被低估:一个没有被预渲染的 URL 就是硬 404,而不是渲染得慢一点。没有任何按需渲染会替你兜住一个漏掉的路径。就在这周,博客索引页的一处改动把回退文章挂到了错误的语言前缀下,这些链接点进去会全部是 404。好在发布前的审查把它拦在了部署之前。

middleware 只能留在边缘运行时。 Next 16 支持以 Node 运行时跑的 proxy 形式 middleware,但适配器不支持,所以我们的仍然是边缘版的 middleware.ts。它的体积也要当回事。我们在 middleware 里去掉了认证库的 auth() 包装,改为用 getToken 直接读 JWT,middleware 包从约 6.1 MB 降到了约 1 MB。

静态是快路径,那就让更多页面变成静态。 在共享 layout 里读取会话,让营销页面没法做成完全静态。我们把这次读取挪进了一个客户端组件,预渲染的路由数从 846 涨到了 1,116。但有个前提:这些页面的边缘缓存只在自定义域名上生效。workers.dev 主机名没有 Cache API,在那上面每个请求都会跑一遍 Worker、读一次 R2。


构建产物从此归你管

预渲染缓存放在 R2 里,上传是你自己的事。 适配器自带的填充步骤走的是一条隧道,而这条隧道从我们的网络出去每次都超时,所以我们改用自己的脚本上传缓存。对象键的格式没有商量余地:

incremental-cache/<buildId>/sha256("/<route>").cache

我们第一版用的是按路径命名的键,结果所有文档页和博客页都返回 404,报 NoFallbackError。在相信自己写的上传脚本之前,先从部署好的 Worker 上抓一个预渲染页面看看。

构建会把环境变量烤进产物里。 构建期的环境变量会以明文形式打进 Worker 包。运行时 Cloudflare 上配置的值优先赋值,烤进去的值只用来补空缺。所以对一个密钥来说,那份烤进去的副本什么用都没有,只是明文躺在产物里。它不会让任何功能出错,所以也不会有任何东西提醒你。我们的部署脚本会在上传前,把所有同时存在于 Worker secret 里的键从烤进去的副本中删掉。最近一次部署删掉了 50 个值。

20 MB 的上传不是一次小 API 调用。 通过本机 HTTP 代理上传约 20 MB 的包,每次都以 EPIPE 或 fetch failed 失败,而走同一个代理的小 API 调用都正常。现在我们的部署会绕开这个代理。

部署要分阶段。 wrangler versions upload 会创建一个流量为 0% 的候选版本。冒烟测试用 Cloudflare 的版本覆盖请求头直接打到这个候选版本上,通过之后才把它提升到 100%。上一个版本的 id 会被记录下来,用于回滚。


默认值不是你以为的那些

爬虫读的是 head。 这一条来自 Next 16 而不是 Workers,但同样该列在这份清单上。Next 16 默认会在动态页面上,针对 Googlebot 把页面元数据流式输出到 </head> 之后。我们把 Googlebot、Bingbot 和几个 AI 爬虫加进了 htmlLimitedBots,让规范链接和标题落在 <head> 里面,也就是爬虫读取它们的地方。

D1 是 SQLite,不是 Postgres。 时间戳从头到尾都是 ISO 字符串。布尔值在传输中是 0 和 1。我们的限流器原本用一条依赖 Postgres 数组参数的多键 upsert 完成,现在变成了每个键一条原子 upsert。


迁移前的检查清单

如果你正准备迁移,下面这些事最好在切流量之前做完,而不是等到用户报 404 之后再补。

  • 在内容管线和依赖里搜一遍,找出所有在请求时编译代码的东西。它们都得挪到构建期。
  • 把每一个需要存在的路由都当作必须预渲染的路由来对待。拿生成的链接去核对生成的路径。
  • middleware 留在边缘运行时,并盯住它的包体积。
  • 想清楚 R2 缓存由谁、用什么方式填充,并用一个真实页面验证键的格式。
  • 去构建出来的 Worker 里找你的环境变量值,把同时也是 secret 的那些删掉。
  • 先上传候选版本,在 0% 流量下做冒烟测试,记下被替换的那个版本。
  • 把你在意的爬虫加进 htmlLimitedBots,然后亲自读一遍线上返回的 head。
  • 在真实域名上测缓存,不要在 workers.dev 上测。
  • 给 SQL 方言差异留出预算:日期、布尔值,以及所有建立在数组上的查询。

OpenNext 回答了"Next.js 能不能跑在 Workers 上":能,足以撑起生产环境。它没有回答"Workers 对我的应用有什么要求"。这份清单本身就是迁移工作,而其中大部分,只有你真正跑在这个平台上之后才会显形。