把 Next.js 从 Vercel 迁移到 Cloudflare Workers:完整迁移清单(2026)
2026-07 更新:这篇文章最初写于我把博客从 Vercel 迁到 Cloudflare 的当口,当时不少细节还是摸着石头过河。一年多过去,这套方案在生产环境稳定跑了很久,本站(meathill.com)本身就是迁移完成态:Next.js 16 + @opennextjs/cloudflare 1.19 + Workers,R2 增量缓存、D1 tag cache、Durable Object 队列全部上齐。这次更新我把迁移路线明确为 Workers(@opennextjs/cloudflare)——截至 2026 年中,Pages + next-on-pages 路线已经不推荐用于新项目——并补上完整的迁移 Checklist、ISR/缓存、next/image、middleware、域名切换与回滚方案,以及我这一年里踩过的真实的坑。配置片段全部来自本站正在线上运行的代码。
一起逃离 Vercel 拥抱 Cloudflare 吧
Vercel 再次调价之后,性价比越来越低,每个月 $20 的额度根本扛不住什么访问量;而且建站所需的各种服务(数据库,KV 等)也欠缺,所以我觉得是时候迁离 Vercel,投奔赛博菩萨 Cloudflare 的怀抱了。当然,该不该迁并不是一句话能说清的事,两家平台各有取舍,我在Vercel vs Cloudflare:怎么选部署平台里做过详细对比,如果你还在犹豫,建议先读那一篇再回来。
不过由于 Cloudflare 平台的整体架构,其 Edge Runtime 和 Serverless runtime 都跟 Vercel,或者说标准架构存在一些不同之处,所以迁移的过程中往往需要我们做一些工作。本篇博客就来分享这些经验。
Cloudflare 云服务
首先,Cloudflare 不仅提供 Serverless / Edge 托管,更提供一整套几乎是必须的服务器组件:
- SQLite 接口的关系型数据库 D1,速度很快,第三方工具很多
- 形似 Redis 的 KV 数据库
- 存储服务 R2,兼容 AWS S3
- Queue、Durable Object 等几乎所有服务器长线运营必须的工具
而且以上大部分都包含慷慨的免费额度,当你的产品度过极早期,需要更多额度时,$5/月也够用很久。总之,对比 Vercel 万国造且每个都要独立付费,当然是 Cloudflare 更好用。
(当然,也会跟 Cloudflare 绑定越来越深,这方面见仁见智吧。)
为什么是 Workers 而不是 Pages
这篇文章最初发布时,Cloudflare 上跑 Next.js 还有两条路线:Pages + @cloudflare/next-on-pages,以及 Workers + @opennextjs/cloudflare。现在不用纠结了:截至 2026 年中,唯一值得选的路线就是 Workers + OpenNext 适配器。Cloudflare 官方的资源都投在 Workers 上,next-on-pages 早已停止跟进新的 Next.js 版本;Pages 平台本身也处于只维护不加新功能的状态。如果你还在 Pages 上跑 Next.js,这次迁移顺便把平台也换了吧。
以我的经验,使用 Worker 而不是 Pages 有以下好处:
- 实时日志和 observability,方便我们查看运行时错误和 debug
- 支持 cron trigger,可以方便的执行一些自动化操作
- 更好的缓存策略,比如预渲染、ISR 增量缓存
- 完整的 Node.js runtime(通过
nodejs_compat),而不是残缺的 Edge runtime
自然,Worker 需要更多的工作,我们必须整体迁移到 OpenNext 才行。
迁移 Checklist
先给一份完整的迁移检查清单。后面的章节会逐项展开,你也可以照着这份清单自查进度:
- 依赖与适配器:安装
@opennextjs/cloudflare和wrangler,卸载@cloudflare/next-on-pages(如有) - wrangler.jsonc:配置
main、assets、compatibility_date、nodejs_compat,以及所有资源绑定(D1/R2/KV/DO) - open-next.config.ts:声明 incremental cache、queue、tag cache 等 override
- 环境变量 / Secret:线上
.env不生效!普通变量进vars,密钥用wrangler secret put,所有绑定都要显式声明 - ISR / 缓存:创建 R2 bucket 并绑定
NEXT_INC_CACHE_R2_BUCKET,按需配置 tag cache 和 revalidation queue - next/image:换成自定义 loader 或 Cloudflare Images,Vercel 的图片优化服务不会跟你走
- middleware:检查是否用到了 Vercel 特有的 geo/ip 字段,改从
request.cf或请求头取 - 移除 Edge runtime 声明:删掉所有
export const runtime = "edge" - 本地验证:
opennextjs-cloudflare preview在 workerd 里跑一遍关键路径 - 首次部署:
pnpm run deploy,用 workers.dev 域名验证 - 域名与 DNS 切换:给 Worker 加 custom domain,把 DNS 从 Vercel 切到 Cloudflare
- CI/CD:关联 GitHub 仓库,配置 Workers Builds 自动部署
- 回滚预案:保留 Vercel 项目做灾备,观察一到两周再下线
迁移到 OpenNext
首先,请参考官方文档:https://opennext.js.org/cloudflare/get-started
接下来,我也会捋一遍迁移过程,并分享我的经验。
安装 @opennextjs/cloudflare
这个适配器会帮我们在 Cloudflare 上运行我们的 Next.js 应用。本站目前用的是 1.19.x,配合 Next.js 16 一切正常。
pnpm install @opennextjs/cloudflare@latest
安装 Wrangler
Wrangler 是 Cloudflare 提供的命令行工具,可以帮我们完成很多工作,也是上面适配器的必备工具。
pnpm install --save-dev wrangler@latest
创建 wrangler 配置文件
这个配置文件会影响到最后的部署和其它云服务使用。我建议大家使用 JSONC 格式,因为语法更熟悉,还能写注释。下面是本站线上正在使用的 wrangler.jsonc(省略了部分业务绑定,ID 类信息做了脱敏):
{
"$schema": "node_modules/wrangler/config-schema.json",
"main": ".open-next/worker.js",
"name": "blog-2026",
"compatibility_date": "2026-01-01",
"compatibility_flags": ["nodejs_compat", "global_fetch_strictly_public"],
"account_id": "<你的 account id>",
"assets": {
"directory": ".open-next/assets",
"binding": "ASSETS",
"html_handling": "auto-trailing-slash",
"not_found_handling": "none"
},
// ISR revalidation 队列,走 Durable Object
"durable_objects": {
"bindings": [
{ "name": "NEXT_CACHE_DO_QUEUE", "class_name": "DOQueueHandler" }
]
},
"migrations": [
{ "tag": "v1", "new_sqlite_classes": ["DOQueueHandler"] }
],
// 业务数据库 + tag cache 数据库
"d1_databases": [
{ "binding": "DB", "database_name": "meathill", "database_id": "<...>" },
{ "binding": "NEXT_TAG_CACHE_D1", "database_name": "tag-cache", "database_id": "<...>" }
],
// 业务存储 + ISR 增量缓存
"r2_buckets": [
{ "binding": "BUCKET", "bucket_name": "blog" },
{ "binding": "NEXT_INC_CACHE_R2_BUCKET", "bucket_name": "site-cache" }
],
"images": { "binding": "IMAGES" },
"observability": { "enabled": true },
"placement": { "mode": "smart" },
"vars": {
"NEXT_PUBLIC_SITE_URL": "https://meathill.com",
"WORDPRESS_API_URL": "https://blog.meathill.com/wp-json/wp/v2"
}
}
几个要点:main 指向 OpenNext 构建产物;assets 指向静态资源目录并绑定为 ASSETS;compatibility_date 尽量用较新的日期;nodejs_compat 是必须的(后文详述);observability 打开后可以在 dashboard 看到结构化日志,排查线上问题全靠它。
添加 open-next.config.ts 配置文件
在根目录添加 open-next.config.ts 配置文件。最小配置只需要 incremental cache,本站的完整配置是这样:
import { defineCloudflareConfig } from '@opennextjs/cloudflare';
import r2IncrementalCache from '@opennextjs/cloudflare/overrides/incremental-cache/r2-incremental-cache';
import doQueue from '@opennextjs/cloudflare/overrides/queue/do-queue';
import d1TagCache from '@opennextjs/cloudflare/overrides/tag-cache/d1-next-tag-cache';
export default defineCloudflareConfig({
// ISR/SSG 页面的增量缓存存进 R2
incrementalCache: r2IncrementalCache,
// stale-while-revalidate 的后台重渲染队列,用 Durable Object 保证去重
queue: doQueue,
// revalidateTag / revalidatePath 的 tag 映射,存在 D1
tagCache: d1TagCache,
// 缓存命中时直接在 Worker 层返回,不进 Next.js server,省 CPU
enableCacheInterception: true,
});
如果你的站点纯 SSR、不用 ISR 和 revalidateTag,只配 incrementalCache 就够了。但只要你用了 revalidate,queue 和 tag cache 建议一起配上,否则后台重验证的行为和 Vercel 上会不一致。enableCacheInterception 强烈建议打开,缓存命中的请求完全不会唤醒 Next.js 的 server 部分,CPU 时间几乎为零。
添加 .dev.vars 文件
在根目录添加 .dev.vars 文件,告诉 Next.js 它应该使用哪一个 .env 文件。
NEXTJS_ENV=development
环境变量与 Secret
环境变量是整个迁移里最容易翻车的一项,值得单独说清楚。核心认知:Workers 线上运行时不读 .env 文件。在 Vercel 上你习惯了在 dashboard 里填环境变量、代码里 process.env.XXX 直接用;在 Cloudflare 上,一切都必须显式声明:
- 普通变量:写进
wrangler.jsonc的vars字段,随代码一起进版本库、一起部署 - 密钥(apiKey、token 等):用
wrangler secret put SOME_KEY推到线上,值不落盘、不进 git - 资源绑定(D1/R2/KV/DO):也都要在
wrangler.jsonc里显式声明,代码里通过getCloudflareContext()拿到env再访问
# 交互式输入,值不会出现在 shell history
wrangler secret put OPENAI_API_KEY
# 查看已配置的 secret(只有名字,看不到值)
wrangler secret list
本地开发则相反:把开发环境所需的变量放在 .env.development(配合上一节的 NEXTJS_ENV=development),本地密钥放 .env 或 .dev.vars 里即可,Next.js dev server 会正常读取。也就是说本地和线上是两套机制,迁移时最好列一张变量清单逐个核对,漏配一个 secret,线上就是运行时报错。
更新 package.json
需要给 package.json 加上以下命令,方便开发和部署。这也是本站现在实际使用的脚本:
"build": "next build",
"preview": "opennextjs-cloudflare build && opennextjs-cloudflare preview",
"deploy": "opennextjs-cloudflare build && opennextjs-cloudflare deploy",
"upload": "opennextjs-cloudflare build && opennextjs-cloudflare upload",
"cf-typegen": "wrangler types --env-interface CloudflareEnv env.d.ts",
其中,deploy 用来完成主动部署,upload 用来部署开发分支,cf-typegen 会根据 wrangler.jsonc 里的绑定生成 CloudflareEnv 类型声明——每次增删绑定后跑一次,TypeScript 就能对 env.DB、env.BUCKET 这些访问做类型检查,非常推荐。
添加静态资源缓存
创建 /public/_headers 文件,添加以下内容,让 Cloudflare CDN 默认缓存所有静态资源,加速网站访问。
/_next/static/*
Cache-Control: public,max-age=31536000,immutable
移除 pages 相关内容
从代码中移除所有 export const runtime = "edge";。这一步非常重要:OpenNext Cloudflare 只打包 Node runtime 的产物,任何声明了 edge runtime 的 route 都会构建出残缺的产物,线上表现是该路由返回 500 或行为诡异。我自己就有一个 API route 因为遗留的 edge 声明坏了很久才发现。
并卸载掉 @cloudflare/next-on-pages(如果你之前走的是 Pages 路线)。
忽略掉 .open-next 和 .wrangler
给 .gitignore 添加更多的忽略项。如果使用 ESLint / Biome,也可能需要添加。
.open-next
.next
.wrangler
本地开发
修改 next.config.ts,增加 @opennextjs/cloudflare 提供的适配器。之后,next dev 里就可以通过 getCloudflareContext() 访问到本地模拟的绑定(D1、R2、KV 都有本地实现)。
import type { NextConfig } from "next";
const nextConfig: NextConfig = {
/* config options here */
};
export default nextConfig;
import { initOpenNextCloudflareForDev } from "@opennextjs/cloudflare";
initOpenNextCloudflareForDev();
【可选】移除首页的预渲染缓存
如果你的首页需要加载远程数据,那么可能需要手动避免首页被预渲染,否则你可能会面对一个静态的首页。解决方案并不复杂,只需要给首页的 page.tsx 里添加下面的语句即可:
// 完全不缓存,每次都重新渲染并加载
export const dynamic = 'force-dynamic';
// 或者如果不希望完全动态,只是希望数据不缓存
// export const revalidate = 0;
不过以现在的经验,我更推荐反过来:能用 ISR 就用 ISR(配合上面的 R2 增量缓存),把 force-dynamic 留给真正需要实时数据的页面。Workers 按 CPU 时间计费,缓存命中越多越省钱。
完成第一次部署
接下来,建议大家执行 pnpm run deploy 完成第一次部署,这样会在 Cloudflare 里添加一个新的 worker,然后我们才好添加 secrets。此时可以先用 *.workers.dev 域名把整站点一遍,确认功能正常再动 DNS。
关联 GitHub 仓库
找到刚才创建的 Worker,在设置面板里找到”构建”(Workers Builds),即可关联到我们的 GitHub 仓库。在构建配置里:
- 删除”构建命令”(Build command)
- 部署命令(Deploy command)为
pnpm run deploy - 非生产分支部署命令(Non-production branches deploy command)为
pnpm run upload
这套配置本站一直在用:push master 就自动构建部署,非生产分支则生成 preview 版本,体验和 Vercel 的 git 集成基本一致。
部署完成
至此,部署链路打通,后面正常推代码就可以触发自动部署了。但迁移还没完——下面几节是从 Vercel 过来最容易被忽略的差异点。
ISR 与缓存:R2 增量缓存
在 Vercel 上,ISR 是平台内置的,你感知不到缓存存在哪。到了 Workers 上,这套东西要自己搭,也就是前面 open-next.config.ts 里那三个 override 的由来:
- incremental cache(R2):预渲染和 ISR 页面的 HTML/RSC 产物存进 R2 bucket。需要先
wrangler r2 bucket create site-cache,再绑定为NEXT_INC_CACHE_R2_BUCKET - queue(Durable Object):页面过期后的后台重渲染任务队列,DO 保证同一路径不会被并发重渲染多次
- tag cache(D1):
revalidateTag()/revalidatePath()依赖的 tag→路径映射,需要单独建一个 D1 数据库绑定为NEXT_TAG_CACHE_D1
验证方式很简单:部署后访问一个 ISR 页面,去 R2 的 bucket 里看有没有出现缓存对象;改动内容后调用 revalidateTag,确认页面在 revalidate 窗口后更新。本站的文章页全部走这套 ISR,发布新文章时通过 tag 失效,体验和 Vercel 时代没有差别,但流量大头都被缓存挡住了。
next/image:图片优化的差异
Vercel 的 next/image 背后是它家的图片优化服务,迁走之后这个服务不会跟你走。在 Cloudflare 上有两条替代路径:
- 自定义 loader + Cloudflare Image Resizing:写一个 loader 把图片 URL 改写成
/cdn-cgi/image/width=...,quality=.../原图地址,在next.config.ts里配置images.loader = 'custom'。要求域名已经在 Cloudflare 上代理 - Cloudflare Images binding:在 Worker 内直接用
env.IMAGES做转码和缩放,适合服务端生成图片的场景(比如动态 OG 图)
export default function cloudflareLoader({
src, width, quality,
}: { src: string; width: number; quality?: number }) {
const params = [`width=${width}`, `quality=${quality ?? 75}`, 'format=auto'];
return `/cdn-cgi/image/${params.join(',')}/${src}`;
}
一个隐蔽的坑:如果你开了 global_fetch_strictly_public(推荐开,防 SSRF),那么在 Worker 里 fetch 同 zone 的 /cdn-cgi/image/... URL 是行不通的——子请求会被打回 Worker 自己,拿到的不是图片。这种服务端场景必须改用 env.IMAGES binding。本站的 OG 图渲染就在这里栽过跟头,后文细说。
middleware 的差异
Next.js middleware 在 OpenNext 下会被打进同一个 Worker 里运行,大部分逻辑(rewrite、redirect、header 处理、next-intl 的 locale 路由)都能直接工作,本站的 i18n middleware 就是零改动迁过来的。需要注意的差异:
- Vercel 特有的
request.geo、request.ip没有了。地理信息改从 Cloudflare 的请求头(cf-ipcountry)或request.cf对象取,IP 用cf-connecting-ip头 - middleware 和页面渲染共享同一个 Worker 的 CPU 预算,别在 middleware 里做重活
- middleware 里发 fetch 同样受子请求限制约束
域名与 DNS 切换
功能验证完,最后一步才是切域名。我的做法:
- 先把域名的 DNS 托管迁到 Cloudflare(如果还没有的话)——反正 Image Resizing、Cache Rules 这些也都依赖它
- 在 Worker 的 Settings → Domains & Routes 里添加 Custom Domain,Cloudflare 会自动创建 DNS 记录和证书,不需要手动配 CNAME
- 确认新域名下所有关键路径正常后,再去 Vercel 上摘掉这个域名
- DNS 迁移期间把 TTL 调低,出问题可以快速切回
顺序很重要:先让 Worker 用 workers.dev 域名跑顺,再切生产域名。切换本身是分钟级的,不需要停机。
回滚方案:保留 Vercel 项目做灾备
不要迁移当天就删 Vercel 项目。我的建议是:
- Vercel 项目保留但摘掉生产域名,让它继续跟着 git 部署(免费额度够用)。这样万一 Workers 侧出严重问题,把域名指回 Vercel 就能回滚,几分钟的事
- 观察一到两周:重点看错误日志(observability 面板)、CPU 时间分布、缓存命中率
- 确认稳定后再下线 Vercel 项目。若代码里已经用上了 D1/R2 绑定这类 Cloudflare 专属 API,回滚窗口就关闭了——所以深度绑定的改造(比如把数据迁进 D1)建议放在迁移稳定之后做
常见坑与排查
下面是这一年里我在生产环境实际踩过、印象最深的几个坑。
Node API 兼容性
Workers 的 runtime 是 workerd 不是 Node.js,nodejs_compat flag 提供了大部分常用 Node API(Buffer、crypto、path、process 等),截至 2026 年中覆盖面已经相当好,本站用到的依赖(drizzle、better-auth、marked 等)都没遇到问题。但仍有例外:依赖原生模块(node-gyp 编译产物)的包、依赖 fs 读本地文件的包跑不了。迁移前用 pnpm run preview 在 workerd 里实测一遍,比看文档列表靠谱。
fetch 子请求限制
Worker 内的 fetch 是”子请求”,有两类限制。其一是数量:每个请求能发起的子请求数有上限,页面渲染时如果对着几十个接口并发 fetch,容易撞墙,尽量合并数据请求。其二是路由:开了 global_fetch_strictly_public 之后,fetch 自己 zone 下的 URL 不会走公网 CDN,而是被打回 Worker 自身——前面 next/image 一节的 cdn-cgi 问题就是这个机制导致的。凡是”Worker 里请求自己域名”的写法都要重新审视,改走 binding(ASSETS、IMAGES、R2)或者 service binding。
CPU 时间与重渲染
Workers 计费和限额看的是 CPU 时间而不是墙钟时间,等待 IO 不算钱,但重计算很贵。最典型的例子是本站的动态 OG 图:用 next/og(Satori)合成 1200×630 的图,一次渲染要做字体排版、图片解码、PNG 编码,再加 JPEG 转码,CPU 开销是普通页面渲染的几十倍。如果每次社交爬虫来抓都现渲染,既慢又烧 CPU 配额。我的方案是渲染结果写进 R2,请求先查 R2、命中直接回(见 src/lib/og/post-image.tsx 的 getOrCreatePostOgJpeg),发布文章时主动重新生成覆盖。一个 OG 图从”每次 500ms+ CPU”变成”R2 读一次”。原则可以推广:任何贵的计算结果,都应该落进 R2/KV,让 Worker 只做查表。
set-cookie 会让缓存失效
只要响应里带 set-cookie,这条响应就会被按 no-store 处理:边缘缓存不存,浏览器 bfcache 也会被禁用。很多库会”顺手”写 cookie——本站踩的是 next-intl 的 locale cookie:明明关了自动语言检测,它还是每个响应都 set 一次 NEXT_LOCALE,导致全站页面在边缘零缓存。修复方式是在 routing 配置里显式关掉 localeCookie,语言切换纯 URL 驱动。迁移之后建议用 curl -I 抽查几个关键页面,确认响应头里没有多余的 set-cookie,缓存相关的 header 符合预期。
本地 preview 与生产差异
日常开发用 next dev(配合 initOpenNextCloudflareForDev,绑定走本地模拟)没问题,但它跑在 Node.js 里,不能代表生产行为。上线前务必用 pnpm run preview(即 opennextjs-cloudflare build && opennextjs-cloudflare preview)在本地 workerd 里完整跑一遍——runtime 兼容性问题、构建产物问题只有在这一步才暴露。还要注意:本地模拟的 D1/R2 是空的独立副本,和线上数据无关;IMAGES 这类 binding 本地没有实现,代码里要做好降级(本站的做法是本地无 env.IMAGES 时跳过转码直接回 PNG)。最后,preview 也不模拟 CPU 限额和子请求配额,这两类问题只能靠线上 observability 日志观察。
总结
回头看,这次迁移是值得的:账单可控、组件齐全,本站在 Workers 上稳定运行至今。整个过程按上面的 Checklist 走,一个周末足够;真正花时间的是迁移之后对缓存、图片、CPU 的持续调优。这部分经验我单独写成了一篇:在 Cloudflare Workers 上部署 Next.js 最佳实践,讲迁移完成后怎么把 ISR 命中率、OG 图、边缘缓存这些做到位,算是本文的下篇。
希望这篇文章对大家有所帮助。如果大家对 Vercel,Cloudflare,Next.js 有任何问题或想法,欢迎留言讨论。
常见问题(FAQ)
从 Vercel 迁到 Cloudflare 难吗?
多数 Next.js 项目用 @opennextjs/cloudflare 适配后代码改动不大,一个周末足够完成。主要工作在环境变量与绑定迁移、ISR/缓存与图片优化的差异处理,以及域名与 DNS 切换;建议按文中 Checklist 逐项核对。
迁移后图片优化怎么办?
Vercel 的 next/image 优化需替换为 Cloudflare Images 或自定义 loader;可配置 images.loader 指向 Cloudflare 的图片缩放服务(/cdn-cgi/image/),服务端生成图片的场景则用 IMAGES binding。
环境变量和密钥如何迁移?
用 wrangler secret put 管理敏感变量,公开变量写入 wrangler 配置的 vars 字段;注意 Workers 线上不读取 .env 文件,D1/R2/KV 等资源也需在配置里显式声明绑定。
延伸阅读
相关文章
Next.js 在 Cloudflare Workers 上生成 OG 图:Satori、缓存与 2026 预热实践
在 Cloudflare Workers 上为 Next.js 生成 Open Graph 图片:Satori/resvg 限制、冷启动与 CPU 时间、R2/CDN 缓存与发布时预热,附可复制的 r
TiDB 账单爆炸之后:一次跌宕起伏的降本排查,从 Cloudflare 边缘到数据库计费内核,降本$30/月
博客的 TiDB 账单逼近限额,RU 基线常年 110+。我用两天逐层排查:边缘缓存、漏洞扫描器、WordPress 对象缓存、连接税、TiFlash 副本、演示数据……一个接一个假设被数据打脸。把能
Cloudflare Email Worker 踩坑实录:三个你一定会遇到的问题
记录在用 Cloudflare Email Worker 处理邮件时遇到的三个常见坑:不能转发到同一 Worker、目标地址需验证、以及 message.raw 只能读一次,并给出用 R2 缓存 .e


