把 Next 博客迁到 Cloudflare Workers:一次真实的升级和补锅记录
升级框架不难,难的是看清哪些事情应该发生在构建期,哪些事情绝对不能留到 Cloudflare Workers 运行时。
先说结论
这次迁移表面上是一次技术栈升级:Node.js 22.22.2、pnpm 11、Next 16、React 19、TypeScript strict、Biome、Zod,再加上 OpenNext 和 Wrangler,把一个博客部署到 Cloudflare Workers。
但真正值得记下来的,不是版本号。
真正的问题有两个:
- Cloudflare Workers 运行时不能假设项目目录还在那里,所以不能在请求时
readdir content/posts。 - Workers 禁止字符串代码生成,所以不能在请求时用
next-mdx-remote/rsc的compileMDX。
这两个点都指向同一个原则:内容型网站尽量把内容发现、frontmatter 校验、Markdown/MDX 编译放到构建期。运行时应该拿已经准备好的数据渲染,而不是临时扫文件、临时编译文章。
升级本身其实挺顺
项目最后固定成了这一套:
{
"engines": {
"node": ">=22.22.2"
},
"packageManager": "pnpm@11.0.9"
}
核心版本也到了新的主线:
{
"next": "16.2.6",
"react": "19.2.6",
"react-dom": "19.2.6",
"typescript": "^6.0.3",
"@biomejs/biome": "2.4.14",
"zod": "^4.4.3"
}
TypeScript 继续开 strict: true,lint 交给 Biome:
{
"scripts": {
"lint": "biome check .",
"typecheck": "tsc --noEmit"
}
}
这些改动不算刺激,但它们很重要。依赖升级以后,项目需要一个稳定的基本盘:同一个 Node 版本、同一个包管理器、同一个格式和 lint 入口。否则后面排查部署问题时,很容易分不清到底是代码问题、依赖问题,还是环境问题。
第一处坑:Cloudflare 部署不是直接跑 next build
最早的部署日志里,next build 已经成功了,但 npx wrangler deploy 进入了一个自动迁移流程:它发现这是 Next 项目,于是想安装 Wrangler、创建配置、跑 OpenNext migrate。
这在本地交互式终端里可能没问题,但在 Cloudflare 的非交互构建环境里就很危险。pnpm 11 又正好更严格,遇到了这个错误:
ERR_PNPM_IGNORED_BUILDS Ignored build scripts: workerd
Run "pnpm approve-builds" to pick which dependencies should be allowed to run scripts.
部署环境不能停下来等你手动选。这个问题的处理方式也很明确:不要让部署命令临时“猜”项目该怎么部署,把配置提交进仓库。
现在项目里有明确的 wrangler.jsonc:
{
"name": "hopes",
"main": ".open-next/worker.js",
"compatibility_date": "2026-05-09",
"compatibility_flags": ["nodejs_compat"],
"assets": {
"binding": "ASSETS",
"directory": ".open-next/assets"
},
"observability": {
"logs": {
"enabled": true,
"invocation_logs": true
},
"traces": {
"enabled": false
}
}
}
OpenNext 的配置也很薄:
import { defineCloudflareConfig, type OpenNextConfig } from '@opennextjs/cloudflare'
export default {
...defineCloudflareConfig(),
buildCommand: 'pnpm next:build',
} satisfies OpenNextConfig
这里的思路是:构建过程由项目自己定义,Wrangler 只负责部署已经生成好的 Worker。
第二处坑:Workers 里没有你的 content/posts 目录
第一次部署后,页面访问失败,日志里有一句很直接:
no such file or directory, readdir '/bundle/content/posts'
这句话基本把问题说完了。原来的内容模块在运行时读取 content/posts,本地 Node 环境当然没问题;但到了 Workers bundle 里,请求时已经没有那个源码目录可以扫了。
于是内容索引被前移到构建期:
const files = (await fs.readdir(postsDir))
.filter(filename => filename.endsWith('.md') || filename.endsWith('.mdx'))
.sort((a, b) => a.localeCompare(b))
脚本读取 MDX 文件、解析 frontmatter 和正文,然后写入:
lib/generated/content-index.json
运行时只 import 这个 JSON:
import generatedContentPosts from './generated/content-index.json'
这个变化让部署稳定了很多。它不是一个华丽改造,但边界变清楚了:文件系统属于构建期,Worker 请求属于运行时。
第三处坑:MDX 编译里藏着 new Function
修完 readdir 后,文章列表能打开,但所有文章详情页还是 500。Cloudflare 日志给了关键线索:
Code generation from strings disallowed for this context
at new Function (<anonymous>)
at compileMDX
这一步挺容易误判。因为本地 Next dev 里文章详情页是正常的,next build 也能过。问题只在 Workers 运行时暴露,因为 Workers 不允许 eval / new Function 这类字符串代码生成。
当时详情页里还在请求时调用:
compileMDX(...)
所以每次访问文章,Worker 都会临时编译 MDX,然后撞上运行时限制。
最后的修法还是同一个方向:继续把事情前移。content:generate 不只生成 frontmatter 和正文,也把文章内容提前转成 HTML:
const markdownProcessor = remark()
.use(remarkGfm)
.use(remarkToc, { heading: 'h2' })
.use(remarkRehype)
.use(rehypeSlug)
.use(rehypeHighlight)
.use(rehypeStringify)
详情页不再运行时编译 MDX,而是渲染构建期生成的 contentHtml。同时移除了不再使用的 next-mdx-remote,避免以后又不小心把同一个问题带回来。
这一步以后,Worker preview 里的博客列表、旧文章详情页、新文章详情页都能正常返回 200。
顺手补上的内容规范
这次还补了几篇文章的 publishedAt frontmatter,并把规则写进了 BLOG.md。
原因也很实际:内容索引读取的是 publishedAt。如果文章只有 date,就可能出现 frontmatter 校验失败,文章也可能不进入列表。
现在至少有几条规则是明确的:
publishedAt必须写,推荐"YYYY-MM-DD"。locale只能是"zh"或"en"。categories和tags用字符串数组。- 文件名建议是
<slug>.<locale>.mdx。
这些规则写给人看,也写给未来的自动化流程看。内容站最怕“看起来只是少了一个字段”,最后排查半天才发现列表没收录。
最后验证了什么
最终我用这些命令收口:
pnpm lint
pnpm typecheck
pnpm build
npx wrangler deploy --dry-run
另外还用 OpenNext 的本地 Worker preview 直接访问了几个路径:
/zh/blog
/zh/blog/multica-shared-human-ai-board
/en/blog/multica-shared-human-ai-board
要看的不是页面“应该可以”,而是 Workers 运行时真的不会再报:
Code generation from strings disallowed for this context
这次真正学到的东西
我以前也知道 Cloudflare Workers 不是普通 Node 服务器,但这次迁移把这个差异压得很实。
在 Node 里很自然的事情,比如扫目录、读本地文件、请求时编译 MDX,到了 Workers 上就要重新判断:它是构建期工作,还是运行时工作?
我的经验是,内容型博客可以尽量保守一点:
- 构建期扫描文章。
- 构建期校验 frontmatter。
- 构建期编译 Markdown/MDX。
- 运行时只读 bundle 里的数据。
这会少一些“动态”的弹性,但换来的是更少的线上惊喜。对一个博客来说,这笔交易挺划算。
最后还有个小提醒:迁移成功不代表文章和样式就自然好看。渲染方式从 MDX React components 换成 HTML 后,原来组件里的 Tailwind class 不会自动跟过去,.prose 样式也要跟着补齐。
框架升级是一半,内容体验是另一半。博客嘛,最终还是要让人愿意读。