从 Vercel 迁移到 Cloudflare Pages:一个周末变三个月的故事
说起来你可能不信,我最初只是想省点钱。
我的网站 Free API Hub 之前一直部署在 Vercel 上,Next.js + Vercel 的组合确实丝滑,push 代码自动部署,预览环境、Edge Functions、图片优化一应俱全。但随着流量慢慢上来,Vercel 的账单也开始让我肉疼——Pro 计划 $20/月,图片优化按调用次数计费,Serverless Function 执行时间超了还要加钱。一个月下来,光部署费用就快 $50 了。
然后我听说了 Cloudflare Pages:500 次构建/月、无限带宽、无限请求,全部免费。我当时就想,这还等什么?
结果这一迁移,从一个周末的小项目变成了三个月的持续踩坑。8 个大坑,每一个都让我怀疑人生。今天把这些坑全部整理出来,如果你也在考虑 Next.js 静态导出 + Cloudflare Pages 部署,这篇文章能帮你省掉至少两个周末。
技术架构全景
先说说我最终的架构方案,这样后面讲坑的时候你才有上下文:
- 框架:Next.js 15 App Router
- 输出模式:
output: "export"静态导出(SSG) - 部署平台:Cloudflare Pages + 自定义域名 524900.xyz
- API 层:Cloudflare Pages Functions(/functions/ 目录)提供 Serverless API
- i18n:支持 zh-CN 和 en-US 两种语言
- 组件模式:Server Component + Client Component 分离
关键的 next.config 配置长这样:
// next.config.ts
const nextConfig = {
output: "export",
trailingSlash: true,
images: {
unoptimized: true,
},
};看着很简单对吧?三行配置而已。但就是这三行配置,背后藏着 8 个坑。
坑 1:next/image 图片优化直接废了
现象
静态导出之后,所有用了 <Image> 组件的页面全部报错:Error: Image Optimization using the default loader is not compatible with output: 'export'。整个网站的图片全部裂开,一个都加载不出来。
原因
Next.js 的 <Image> 组件默认使用服务端图片优化,它会在请求时动态压缩、转换图片格式(比如转 WebP),这需要 Node.js 运行时。但 output: "export" 生成的是纯静态 HTML,没有服务端,图片优化自然就废了。
解决方案
在 next.config 里加 images: { unoptimized: true },禁用 Next.js 的图片优化。然后用其他方案替代:
- 手动用 Sharp 或 Squoosh 提前压缩图片
- 用 Cloudflare Image Resizing(需要付费计划)
- 我最终选的方案:直接用原始图片 + 懒加载,配合 Cloudflare CDN 的自动缓存,实际体验差别不大
说实话这个坑不算意外,Next.js 官方文档写了,但很多人(包括我)都是先写代码再看文档,结果一导出全炸了。
坑 2:i18n 路由 404——这个坑让我差点放弃
现象
这是最让我崩溃的一个坑。网站支持中英文切换,我用了 Next.js 的 i18n 路由方案,中文页面路径是 /zh-CN/mcp/,英文是 /en-US/mcp/。但我在 sitemap 里写的是 /mcp/,因为我觉得用户不应该看到语言前缀。
结果呢?Google 爬虫按照 sitemap 抓取 /mcp/,返回 404。Google Search Console 里全是 404 错误,收录量断崖式下跌。我花了整整一个周末才搞明白为什么 Google 收录的全是 404。
原因
静态导出后,Next.js 不会自动处理 i18n 路由重定向。也就是说,/mcp/ 这个路径在静态文件系统里根本不存在,只有 /zh-CN/mcp/ 和 /en-US/mcp/ 存在。Vercel 部署时 Next.js 的中间件会自动处理这个重定向,但 Cloudflare Pages 不会。
解决方案
在 public 目录下创建 _redirects 文件:
# public/_redirects
/mcp/ /zh-CN/mcp/ 308
/api/ /zh-CN/api/ 308
/ /zh-CN/ 308Cloudflare Pages 会读取这个文件,在 CDN 层做重定向。308 是永久重定向,告诉搜索引擎这个页面永久迁移了。
但这里有个细节:如果你同时用了 _routes.json,要注意 _redirects 的优先级。我的 _routes.json 配置了 API 路由:
// public/_routes.json
{
"version": 1,
"include": ["/api/*"],
"exclude": []
}这个配置告诉 Cloudflare Pages Functions 处理 /api/* 路径的请求。_redirects 和 _routes.json 的优先级是:_redirects 先执行,然后才是 Pages Functions。所以 /api/* 不会被 _redirects 拦截。
坑 3:trailingSlash 不一致导致 Google 收录大乱
现象
Google Search Console 显示同一个页面有两个 URL:/mcp 和 /mcp/。而且更离谱的是,有时候返回 308 重定向,有时候返回 301,Google 把它们当成了两个不同的页面,权重被分散了。
原因
Next.js 默认不带尾斜杠,但 Cloudflare Pages 的静态文件服务默认带尾斜杠(因为 /mcp/index.html 对应 /mcp/)。两边的重定向行为不一致,就导致了 308 和 301 混乱。
解决方案
在 next.config 里统一设置 trailingSlash: true,这样 Next.js 生成的所有链接都会带尾斜杠,跟 Cloudflare Pages 的行为一致。同时在 _headers 文件里设置规范 URL:
# public/_headers
/zh-CN/*
X-Robots-Tag: index, follow
Cache-Control: public, max-age=3600, s-maxage=86400
/en-US/*
X-Robots-Tag: index, follow
Cache-Control: public, max-age=3600, s-maxage=86400设置完之后,Google Search Console 里的重复页面问题慢慢消失了,大概两周后收录恢复正常。
坑 4:Cloudflare Pages Functions 的 TypeScript 编译问题
现象
我在 /functions/ 目录下写了 Pages Functions,用的 TypeScript。本地开发一切正常,但推到 Cloudflare Pages 构建时,Next.js 的构建过程会尝试编译 /functions/ 目录下的 TypeScript 文件,然后报一堆类型错误,构建直接失败。
原因
Next.js 的 TypeScript 编译器默认会扫描项目根目录下的所有 .ts 和 .tsx 文件,包括 /functions/ 目录。但 Cloudflare Pages Functions 的运行时是 Worker,跟 Next.js 的运行时完全不同,类型定义也不一样。Next.js 编译它当然会出错。
解决方案
在 tsconfig.json 里排除 /functions/ 目录:
// tsconfig.json
{
"compilerOptions": {
// ... 其他配置
},
"exclude": ["node_modules", "functions"]
}然后给 /functions/ 目录单独创建一个 tsconfig.json,使用 Cloudflare Worker 的类型定义。这样两边互不干扰。
坑 5:blog.ts 里的嵌套模板字符串报错
现象
这个坑比较小众,但如果你也在用类似的方式管理博客内容,一定会遇到。我在 blog.ts 里用模板字符串写文章内容,文章里又有代码示例,代码示例里又有模板字符串(比如 JavaScript 的模板字面量)。反引号套反引号,直接语法报错。
原因
JavaScript 的模板字符串里不能再嵌套未转义的反引号,这是语法限制。
解决方案
两种方法:一是用 ` 转义内部的反引号(但可读性很差),二是把代码示例里的模板字符串改用字符串拼接。我选了后者,虽然代码示例不够优雅,但至少不会报错。
// 原来的写法(会报错):
const url = "/api/" + "${id}"; // 模板字符串嵌套会报错
// 改成字符串拼接:
const url = "/api/" + id;小坑,但当时排查了半天,因为报错信息只说"Unexpected token",没说是模板字符串嵌套的问题。
坑 6:Server Component 里用了 useState 导致 Hydration Mismatch
现象
网站的搜索功能突然不工作了。点击搜索按钮没反应,控制台报错:Hydration failed because the server rendered HTML didn't match the client。更诡异的是,这个 bug 只在生产环境出现,本地开发完全正常。
原因
Next.js 15 App Router 默认所有组件都是 Server Component。我在 page.tsx 里直接用了 useState 和 onClick,这些是客户端 API,不能在 Server Component 里使用。本地开发时 Next.js 做了兼容处理,但静态导出后这个兼容就没了。
解决方案
把交互逻辑拆到 Client Component 里:
// page.tsx (Server Component)
import SearchBox from './SearchBox';
export default function Page() {
return (
<div>
<h1>Free API Hub</h1>
<SearchBox /> {/* Client Component */}
</div>
);
}
// SearchBox.tsx (Client Component)
'use client';
import { useState } from 'react';
export default function SearchBox() {
const [query, setQuery] = useState('');
// ... 搜索逻辑
}关键就是 'use client' 这个指令。App Router 下,只有标记了 'use client' 的组件才能使用 hooks 和事件处理器。这个拆分模式是 Next.js App Router 的核心设计,但如果你是从 Pages Router 迁移过来的,很容易忽略这一点。
坑 7:_redirects 文件被误删导致全站 404
现象
某天早上起来一看,网站全站 404。不是某个页面 404,是所有页面都 404。Google Search Console 的错误数从个位数飙到了几百。
原因
我在做代码清理的时候,把 public/_redirects 文件当成临时文件删了。因为静态导出后 public/ 目录下的文件会被直接复制到输出目录,而 _redirects 看起来不像是个重要文件——它没有扩展名,编辑器也不会高亮它。
解决方案
两个措施:一是在 .gitignore 里明确不忽略 public/_redirects、public/_headers、public/_routes.json 这些文件;二是在 CI 里加了个检查步骤,确保这些文件存在:
# CI 检查脚本
if [ ! -f "public/_redirects" ]; then
echo "ERROR: public/_redirects is missing!"
exit 1
fi
if [ ! -f "public/_headers" ]; then
echo "ERROR: public/_headers is missing!"
exit 1
fi这个坑让我深刻认识到:Cloudflare Pages 的配置文件(_redirects、_headers、_routes.json)虽然不起眼,但它们是整个路由系统的命脉。删了它们,网站就等于废了。
坑 8:Cloudflare Pages 构建时 tsx 命令找不到
现象
我的 Pages Functions 用 TypeScript 写的,构建脚本里用 tsx 来运行。本地没问题,但 Cloudflare Pages 构建时总是报 tsx: command not found。我在 package.json 的 devDependencies 里加了 tsx,但每次 npm install 之后 tsx 就不见了。
原因
Cloudflare Pages 的构建环境会先执行 npm install,然后执行你指定的构建命令。但它的 npm install 默认不安装 devDependencies(生产模式)。而 tsx 是开发依赖,放在 devDependencies 里,所以构建时找不到。
解决方案
把 tsx 放到 dependencies 而不是 devDependencies。虽然不太规范,但 Cloudflare Pages 的构建环境就是这么设计的。或者更好的方案:不用 tsx,直接用 wrangler 来构建和部署 Pages Functions,wrangler 是 Cloudflare 自己的工具,原生支持 TypeScript。
# 用 wrangler 构建 Pages Functions
npx wrangler pages functions build --outdir dist/functions后来我把整个 Functions 的构建流程都迁移到了 wrangler,再也没有 tsx 找不到的问题了。
性能优化成果
踩完 8 个坑之后,网站终于稳定运行了。来看看性能数据:
- Lighthouse 评分:Performance 96,Accessibility 100,Best Practices 95,SEO 98
- LCP(最大内容绘制):1.2s(Cloudflare CDN 全球加速)
- CLS(累积布局偏移):0.02(字体预加载 + 尺寸占位)
- FID(首次输入延迟):8ms(静态页面几乎没有 JS 执行开销)
- PageSpeed Insights 移动端评分:92 分
说实话,静态导出 + CDN 的性能上限确实比 SSR 高。因为所有页面都是预生成的 HTML,用户访问时直接从最近的 CDN 节点返回,不需要服务端渲染。唯一的代价是构建时间稍长(全站 500+ 个 API 页面,构建大概 3 分钟),但 Cloudflare Pages 的 500 次构建/月完全够用。
成本分析:Cloudflare Pages vs Vercel
最后算一笔账,这也是我迁移的初衷:
- Cloudflare Pages 免费额度:500 次构建/月、无限带宽、无限请求、100MB Functions/次调用
- Vercel 免费额度:100 次构建/月、100GB 带宽/月、Serverless Function 10s 超时
- Vercel Pro:$20/月,6000 分钟构建时间、1TB 带宽、Function 60s 超时
对于 Free API Hub 这个量级的网站,Cloudflare Pages 的免费额度完全够用,每月成本是 $0。而之前在 Vercel 上,因为图片优化和 Function 执行时间的额外计费,每月要花 $20-50。
当然,Cloudflare Pages 也有它的局限:不支持 Next.js 的 ISR(增量静态再生成)、不支持 Server Actions、图片优化需要付费计划。如果你的项目重度依赖这些功能,还是老老实实用 Vercel 吧。
技术选型建议
最后给正在纠结技术选型的朋友一些建议:
- 选 Cloudflare Pages + 静态导出的场景:内容型网站、文档站、博客、API 目录(比如我的 Free API Hub)、营销页面。这些网站的内容更新频率低、交互简单,静态导出的性能优势最明显。
- 还是用 Vercel 的场景:需要 ISR、需要 Server Actions、重度依赖 next/image 优化、需要预览部署的团队协作场景。
- 别碰的场景:需要用户登录态的动态页面、实时数据面板、电商购物车——这些场景静态导出根本不合适,别为难自己。
Free API Hub (524900.xyz) 现在就跑在这个架构上,你可以直接访问体验一下效果。如果你也在做 Next.js 静态站点生成 SSG 的项目,希望这篇文章能帮你少踩几个坑。有问题欢迎在网站留言,我看到都会回复。
最后一句:技术选型没有银弹,只有最适合你项目的方案。但踩过的坑一定要记下来——不是为了证明自己多厉害,而是为了让后来的人少走弯路。