Vercel Was Too Expensive, So I Migrated My Next.js App to Cloudflare Workers
本文为原文前 6,000 字符的节选翻译,完整内容请查看原文。
Vercel Was Too Expensive, So I Migrated My Next.js App to Cloudflare Workers
最近,我将我的 Next.js 电影/电视发现网站 Galaxy Movies 的生产流量从 Vercel 迁移到了 Cloudflare Workers。这并不是要抨击 Vercel:Vercel 是一个很棒的平台,我的应用在那里运行了很长一段时间。但我为一个最终只是副业的项目支付 Pro 套餐费用,而且我的 DNS 本来就在 Cloudflare 上,整个后端接口都是基于 fetch 的(Upstash REST、OpenAI、Resend、TMDB);没有数据库,没有 TCP。对于在 Workers 上运行 Next.js 来说,这几乎是最佳场景。该网站使用 Next.js 16 Pages Router、React 19、Chakra UI,包含 ISR 目录页面、一堆 API 路由、四个 Vercel 定时任务以及通过 next/og 生成的动态 OG 图片。以下是迁移的实际过程,包括那些棘手的难点。
The adapter: @opennextjs/cloudflare. The OpenNext Cloudflare adapter takes a standard next build output and rebundles it into a worker. Setup is two files. First, open-next.config.ts. The interesting bit is the incremental cache override, which points ISR’s cache at an R2 bucket:
适配器:@opennextjs/cloudflare。OpenNext Cloudflare 适配器获取标准的 Next 构建输出并将其重新打包为 Worker。设置只需两个文件。首先是 open-next.config.ts。其中有趣的部分是增量缓存覆盖,它将 ISR 的缓存指向一个 R2 存储桶:
import { defineCloudflareConfig } from '@opennextjs/cloudflare'
import r2IncrementalCache from '@opennextjs/cloudflare/overrides/incremental-cache/r2-incremental-cache'
export default defineCloudflareConfig({
default: { experimentalBundledNextServer: true },
incrementalCache: r2IncrementalCache
})
Then wrangler.jsonc:
然后是 wrangler.jsonc:
{
"main": ".open-next/entry.js",
"name": "galaxy-movies",
"compatibility_flags": ["nodejs_compat", "global_fetch_strictly_public"],
"assets": {
"directory": ".open-next/assets",
"binding": "ASSETS"
},
"services": [{ "binding": "WORKER_SELF_REFERENCE", "service": "galaxy-movies" }],
"r2_buckets": [
{
"binding": "NEXT_INC_CACHE_R2_BUCKET",
"bucket_name": "galaxy-movies-inc-cache"
}
],
"triggers": {
"crons": ["0 6 * * *", "30 6 * * *", "0 7 * * *", "0 16 * * 5"]
},
"find_additional_modules": true,
"rules": [
{
"type": "Data",
"globs": ["public/fonts/**", "public/backdrop_fallback.jpg"],
"fallthrough": true
}
]
}
Deploy is two commands: opennextjs-cloudflare build then wrangler deploy. Everything below is the gap between “two commands” and “actually works in production.”
部署只需两条命令:opennextjs-cloudflare build 和 wrangler deploy。以下所有内容都是“两条命令”与“在生产环境中实际运行”之间的差距。
Gotcha 1: Next’s dev-only require()s break the bundle. The adapter bundles the Next server with esbuild, and esbuild wants every require() to resolve, including dev-only code paths inside Next’s own dist files (next-dev-server, setup-dev-bundler, an upgrade nudge) and an RSC-only path (react-server-dom-webpack) that a Pages Router app never executes. They’re dead code in production, but dead code still has to resolve. I fixed it with patch-package to mark them external in the adapter’s bundle step, plus a postbuild script that drops tiny stub modules where the runtime resolution lands.
陷阱 1:Next 仅用于开发的 require() 会破坏打包。适配器使用 esbuild 打包 Next 服务器,而 esbuild 要求每个 require() 都能解析,包括 Next 自身 dist 文件中仅用于开发的路径(next-dev-server、setup-dev-bundler、升级提示)以及 Pages Router 应用从不执行的仅限 RSC 的路径(react-server-dom-webpack)。它们在生产环境中是死代码,但死代码仍然需要被解析。我使用 patch-package 在适配器的打包步骤中将它们标记为外部依赖,并添加了一个构建后脚本,在运行时解析的位置放置了微小的存根模块。
Gotcha 2: next/og picks the wrong runtime. This one took the longest. I generate OG images with next/og, which wraps @vercel/og. The library has two entries: a Node build that does readFileSync on a bundled font and calls WebAssembly.instantiate at runtime (both disallowed on workerd), and an edge build that doesn’t. The problem: OpenNext forces NEXT_RUNTIME=“nodejs” at bundle time, so next/og always resolves the Node entry, even inside a worker. The workaround is sniffing the runtime myself and importing the edge build directly.
陷阱 2:next/og 选择了错误的运行时。这个问题耗时最长。我使用 next/og 生成 OG 图片,它封装了 @vercel/og。该库有两个入口:一个是 Node 构建版本,它对打包的字体执行 readFileSync 并在运行时调用 WebAssembly.instantiate(这两者在 workerd 上都不被允许);另一个是不执行这些操作的 Edge 构建版本。问题在于:OpenNext 在打包时强制设置了 NEXT_RUNTIME=“nodejs”,因此 next/og 总是解析 Node 入口,即使在 Worker 内部也是如此。解决方法是自己检测运行时并直接导入 Edge 构建版本。
Gotcha 3: fs reads land in the wrong directory. My OG route also reads custom fonts and a fallback backdrop off disk with readFile(path.join(process.cwd(), ‘public’, …)). On Vercel, process.cwd() is the project root. On workerd, the vfs cwd is /bundle (the .open-next output root), so public/fonts doesn’t exist where the code looks for it. The fix is a three-part move: the postbuild script copies public/fonts and backdrop_fallback.jpg into .open-next/public/, and wrangler’s find_additional_modules + the Data rules in wrangler.jsonc ship those files in the worker’s virtual filesystem. Same code path, files just exist where the worker looks.
陷阱 3:fs 读取落在了错误的目录中。我的 OG 路由还通过 readFile(path.join(process.cwd(), ‘public’, …)) 从磁盘读取自定义字体和后备背景图。在 Vercel 上,process.cwd() 是项目根目录。在 workerd 上,vfs 的当前工作目录是 /bundle(.open-next 输出根目录),因此代码查找的位置不存在 public/fonts。修复方法分为三步:构建后脚本将 public/fonts 和 backdrop_fallback.jpg 复制到 .open-next/public/ 中,并且 wrangler 的 find_additional_modules 以及 wrangler.jsonc 中的 Data 规则将这些文件放入 Worker 的虚拟文件系统中。代码路径相同,只是文件存在于 Worker 查找的位置。
Gotcha 4: packages hiding behind the workerd export condition. Some packages, in my case @emotion/* (Chakra’s styling engine) and uncrypto, publish their ESM builds behind a workerd export condition. Next’s file tracer only follows the CJS variants, so the worker bundle ends up with nothing it can resolve for those imports. The fix is listing them in serverExternalPackages in next.config.ts, which tells the adapter to copy the packages whole instead of relying on tracing.
陷阱 4:隐藏在 workerd 导出条件后的包。有些包(在我的例子中是 @emotion/*(Chakra 的样式引擎)和 uncrypto)将其 ESM 构建版本发布在 workerd 导出条件之后。Next 的文件追踪器只追踪 CJS 变体,因此 Worker 打包结果中没有这些导入所需的任何内容。解决方法是在 next.config.ts 的 serverExternalPackages 中列出它们,这会告诉适配器完整复制这些包,而不是依赖追踪。
Gotcha 5: Vercel cron.
陷阱 5:Vercel 定时任务。