Logo

Next.js 部署到 Cloudflare Pages:静态资源 404 问题完全解决

Published on
...
Authors

背景

最近将博客从 Hugo 迁移到 Next.js,选择部署到 Cloudflare Pages。虽然部署成功,页面内容也能正常显示,但遇到了一个棘手的问题:所有 CSS 和 JavaScript 文件都返回 404,页面完全没有样式

用 Chrome DevTools 检查后发现:

Refused to apply style from 'https://geekhuashan.com/_next/static/css/xxx.css'
because its MIME type ('text/html') is not a supported stylesheet MIME type

这个错误信息说明:虽然浏览器请求了 CSS 文件,但服务器返回的是 HTML 内容(可能是 404 页面),而不是实际的 CSS 文件。

技术栈

  • 框架: Next.js 15.5.2(App Router)
  • 适配器: @opennextjs/cloudflare 1.13.0
  • 部署平台: Cloudflare Pages
  • 样式: Tailwind CSS v4.1.17

问题诊断过程

第一步:确认文件存在

首先检查构建产物,确认 CSS 文件确实存在:

find .open-next/assets -name "*.css"

文件存在,说明不是构建问题。

第二步:检查路径映射

分析 HTML 源码和实际文件路径:

  • HTML 引用: /_next/static/css/6f6a8d0aee9e0fd8.css
  • 实际文件位置: .open-next/assets/_next/static/css/6f6a8d0aee9e0fd8.css
  • Cloudflare Pages 根目录: .open-next/

问题出现了!Cloudflare Pages 将 .open-next/ 作为根目录,所以实际可访问路径是:

  • /assets/_next/static/css/xxx.css(实际路径)
  • /_next/static/css/xxx.css(HTML 期望路径)

路径不匹配!

第三步:检查 Cloudflare Pages 配置

查看 wrangler.toml

name = "blog-nextjs-cdj"
compatibility_date = "2024-11-18"
compatibility_flags = ["nodejs_compat"]
pages_build_output_dir = ".open-next"

配置看起来正常,但缺少关键的 _routes.json 文件来告诉 Cloudflare 哪些路径应该直接提供静态文件。

尝试过的方案(未成功)

方案 1:设置 assetPrefix ❌

next.config.js 中添加:

const assetPrefix = '/assets'

结果:这会让 Next.js 生成 /assets/_next/... 路径,但与实际路径 /assets/_next/... 仍然不匹配,而且还会影响其他路径。

方案 2:重命名 _next 目录 ❌

尝试将 _next 重命名为 next(因为 Cloudflare 会忽略以 _ 开头的目录):

mv .open-next/assets/_next .open-next/assets/next

结果:HTML 仍然引用 /_next/...,路径还是对不上。

方案 3:添加 _redirects 文件 ❌

创建 Cloudflare Pages 重定向规则:

/_next/* /assets/next/:splat 200
/static/* /assets/next/static/:splat 200

结果:在 Worker 模式下(有 _worker.js 文件),_redirects 文件不会生效。

最终解决方案 ✅

经过多次尝试,找到了根本解决方案:调整目录结构 + 配置 _routes.json

步骤 1:修改构建脚本

修改 package.json 中的 pages:build 脚本:

{
  "scripts": {
    "pages:build": "npx @opennextjs/cloudflare build && mv .open-next/worker.js .open-next/_worker.js && cp -r .open-next/assets/* .open-next/ && node -e \"require('fs').writeFileSync('.open-next/_routes.json', JSON.stringify({version:1,include:['/*'],exclude:['/_next/static/*','/favicon.ico','/robots.txt','/sitemap.xml','/feed.xml','/404.html','/BUILD_ID','/search.json','/tags/*']},null,2))\""
  }
}

关键改动:

  1. 复制 assets 到根目录cp -r .open-next/assets/* .open-next/

    • _nextfeed.xmlsearch.json 等文件从 assets/ 移到根目录
    • 这样 HTML 引用的 /_next/... 就能正确映射到文件了
  2. 自动生成 _routes.json

    node -e "require('fs').writeFileSync('.open-next/_routes.json',
      JSON.stringify({
        version: 1,
        include: ['/*'],
        exclude: [
          '/_next/static/*',
          '/favicon.ico',
          '/robots.txt',
          '/sitemap.xml',
          '/feed.xml',
          '/404.html',
          '/BUILD_ID',
          '/search.json',
          '/tags/*'
        ]
      }, null, 2))"
    

步骤 2:理解 _routes.json 的作用

_routes.json 是 Cloudflare Pages 的路由配置文件,用于控制哪些请求走 Worker,哪些直接返回静态文件:

{
  "version": 1,
  "include": ["/*"],          // 所有路径默认走 Worker
  "exclude": [                // 这些路径直接返回静态文件
    "/_next/static/*",        // Next.js 静态资源
    "/favicon.ico",
    "/robots.txt",
    "/sitemap.xml",
    "/feed.xml"
  ]
}

为什么这很重要?

  • 不经过 Worker 处理:静态文件直接由 Cloudflare CDN 提供,速度更快且不计费
  • 正确的 MIME 类型:Cloudflare 会根据文件扩展名自动设置正确的 Content-Type
  • 避免 404:告诉 Cloudflare 这些路径对应的是真实的静态文件

步骤 3:验证部署

构建并部署:

npm run pages:build
wrangler pages deploy .open-next/ --project-name=blog-nextjs --branch=main

检查最终目录结构:

.open-next/
├── _worker.js              # Cloudflare Worker 入口
├── _routes.json            # 路由配置
├── _next/                  # Next.js 静态资源(从 assets 复制过来)
│   └── static/
│       ├── css/
│       │   └── 6f6a8d0aee9e0fd8.css
│       ├── chunks/
│       └── media/
├── assets/                 # 原始 assets 目录(保留)
├── feed.xml
├── search.json
└── tags/

步骤 4:测试验证

访问网站并检查:

curl -I https://geekhuashan.com/_next/static/css/6f6a8d0aee9e0fd8.css

返回:

HTTP/2 200
content-type: text/css
cache-control: public, max-age=31536000, immutable

成功!CSS 文件正常返回,MIME 类型正确!

核心原理总结

问题根源

Cloudflare Pages 的目录结构要求:

  1. pages_build_output_dir 指定的目录(.open-next/)是 Web 根目录
  2. 浏览器访问 /path/to/file 会映射到 .open-next/path/to/file
  3. 如果文件在 .open-next/assets/_next/...,访问路径就是 /assets/_next/...
  4. 但 Next.js 生成的 HTML 引用的是 /_next/...

解决方案核心

让文件路径与 HTML 引用匹配

  • HTML 引用:/_next/static/css/xxx.css
  • 文件位置:.open-next/_next/static/css/xxx.css
  • 访问路径:/_next/static/css/xxx.css

配置 _routes.json 优化访问

  • 静态资源不经过 Worker,直接由 CDN 提供
  • 正确设置 MIME 类型
  • 提升性能,减少计费

经验教训

1. 理解部署平台的目录结构

不同平台对构建产物的目录结构有不同要求:

  • Vercel:直接使用 .next/ 目录
  • Cloudflare Pages:需要 pages_build_output_dir 指定的目录作为根目录
  • AWS/Amplify:通常使用 out/ 目录

2. 路径映射是关键

部署问题中,80% 都与路径映射有关:

HTML 引用路径 = Web 访问路径 = 文件系统路径(相对于根目录)

任何一个环节不匹配都会导致 404。

3. 使用开发者工具诊断

Chrome DevTools 的 Network 面板是最好的朋友:

  • Status Code:200(成功),404(未找到),403(权限问题)
  • Content-Type:检查 MIME 类型是否正确
  • Headers:查看是否有重定向、缓存等问题

4. 检查 _routes.json

Cloudflare Pages 的 _routes.json 文件非常重要,它决定了:

  • 哪些请求走 Worker
  • 哪些请求直接返回静态文件
  • 如何优化性能和成本

5. 保留调试信息

每次尝试都做好记录(commit message):

git log --oneline
# 4b0d30e - fix: rename _next to next and add redirects
# 15c82a2 - fix: remove assetPrefix to fix static asset loading
# 87d7553 - fix: add /static/* redirect rule for CSS and JS assets
# c7d871c - fix: keep _next directory name and remove custom redirects
# 441e9d4 - fix: add _routes.json generation to build script
# 666857b - fix: copy assets to root directory for correct static file paths ✅

完整解决方案代码

package.json

{
  "scripts": {
    "pages:build": "npx @opennextjs/cloudflare build && mv .open-next/worker.js .open-next/_worker.js && cp -r .open-next/assets/* .open-next/ && node -e \"require('fs').writeFileSync('.open-next/_routes.json', JSON.stringify({version:1,include:['/*'],exclude:['/_next/static/*','/favicon.ico','/robots.txt','/sitemap.xml','/feed.xml','/404.html','/BUILD_ID','/search.json','/tags/*']},null,2))\"",
    "pages:deploy": "npm run pages:build && wrangler pages deploy .open-next/ --project-name=blog-nextjs"
  }
}

wrangler.toml

name = "blog-nextjs"
compatibility_date = "2024-11-18"
compatibility_flags = ["nodejs_compat"]
pages_build_output_dir = ".open-next"

next.config.js

module.exports = () => {
  const plugins = [withContentlayer, withBundleAnalyzer]
  return plugins.reduce((acc, next) => next(acc), {
    reactStrictMode: true,
    // 不需要设置 assetPrefix 或 basePath
    images: {
      unoptimized: true, // Cloudflare Pages 需要
    },
    // ... 其他配置
  })
}

番外篇:博客文章 404 问题

在解决了静态资源 404 之后,又遇到了一个新问题:所有博客文章页面都返回 404!虽然首页和列表页正常,但访问单独的文章(如 /blog/chai-shu-reading-notes)时会看到 404 页面。

问题现象

curl -I https://geekhuashan.com/blog/chai-shu-reading-notes
# HTTP/2 404 Not Found

但本地开发环境 (npm run dev) 一切正常:

curl -I http://localhost:3000/blog/chai-shu-reading-notes
# HTTP/1.1 200 OK

诊断过程

第一步:检查预渲染文件

首先检查 Next.js 构建产物中是否有预渲染的 HTML 文件:

ls .next/server/app/blog/
# chai-shu-reading-notes.html  ✅ 文件存在
# chai-shu-reading-notes.meta
# chai-shu-reading-notes.rsc

文件确实存在,并且已经被复制到部署目录:

ls .open-next/blog/
# chai-shu-reading-notes.html  ✅

第二步:检查预渲染清单

查看 Next.js 的预渲染清单,确认路由已正确生成:

cat .next/prerender-manifest.json | grep chai-shu
# "/blog/chai-shu-reading-notes": {}  ✅

路由配置正确,说明问题不在构建阶段。

第三步:本地测试 Worker

使用 Wrangler 在本地启动 Cloudflare Pages 预览:

cd .open-next && npx wrangler pages dev . --port 8788

访问测试:

curl -I http://localhost:8788/blog/chai-shu-reading-notes
# HTTP/1.1 404 Not Found

在控制台看到了关键错误:

[ERROR] Uncaught Error: Internal: NoFallbackError Error
    at responseGenerator
    at null.<anonymous>

找到根本原因了!

问题根源:NoFallbackError

NoFallbackError 是 OpenNext Cloudflare 适配器的一个已知问题。当 Worker 尝试访问预渲染页面的缓存时,如果缓存系统配置不当或无法访问,就会抛出这个错误。

OpenNext 默认使用本地文件系统作为缓存,但在 Cloudflare Pages 的生产环境中:

  1. Worker 无法直接访问文件系统缓存
  2. 需要使用 R2 或 KV 存储作为缓存后端
  3. 或者,将这些页面作为静态文件提供,绕过 Worker

解决方案:静态化博客文章

既然博客文章是预渲染的静态内容,为什么还要让 Worker 处理呢?最简单的方案是:将博客文章作为静态文件直接由 CDN 提供

步骤 1:创建符合 Cloudflare Pages 规范的目录结构

Cloudflare Pages 支持"漂亮 URL"(pretty URLs),会自动将 /blog/post-name 映射到 /blog/post-name/index.html

我们需要将:

blog/
└── chai-shu-reading-notes.html

转换为:

blog/
└── chai-shu-reading-notes/
    └── index.html

步骤 2:创建自动化脚本

创建 scripts/create-blog-structure.mjs

import fs from 'fs'
import path from 'path'

const blogDir = '.open-next/blog'
const tagsDir = '.open-next/tags'

console.log('🔧 Creating directory structure for blog posts...')

function createIndexStructure(dir) {
  if (!fs.existsSync(dir)) {
    console.log(`   ⚠️  Directory ${dir} does not exist, skipping...`)
    return
  }

  const files = fs.readdirSync(dir)
  let count = 0

  for (const file of files) {
    if (file.endsWith('.html')) {
      const basename = file.replace('.html', '')
      const targetDir = path.join(dir, basename)
      const targetFile = path.join(targetDir, 'index.html')
      const sourceFile = path.join(dir, file)

      // 创建目录
      if (!fs.existsSync(targetDir)) {
        fs.mkdirSync(targetDir, { recursive: true })
      }

      // 复制 HTML 文件为 index.html
      fs.copyFileSync(sourceFile, targetFile)
      count++
    }
  }

  return count
}

try {
  const blogCount = createIndexStructure(blogDir)
  const tagsCount = createIndexStructure(tagsDir)

  console.log(`✅ Created ${blogCount || 0} blog post directories`)
  console.log(`✅ Created ${tagsCount || 0} tag directories`)
  console.log('✨ Directory structure created successfully!')
} catch (error) {
  console.error('❌ Error creating directory structure:', error.message)
  process.exit(1)
}

步骤 3:更新 _routes.json

修改 scripts/fix-routes.mjs,将 /blog/* 从 Worker 排除:

routes = {
  version: 1,
  include: ['/*'],
  exclude: [
    '/_next/static/*',
    '/_next/data/*',
    '/static/*',
    '/images/*',
    '/blog/*',           // ✅ 博客文章作为静态文件
    '/tags/*',           // ✅ 标签页面也作为静态文件
    '/favicon.ico',
    '/robots.txt',
    '/sitemap*.xml',
    '/feed.xml',
    '/rss.xml',
    '/404.html',
    '/BUILD_ID',
    '/search.json',
  ],
}

关键变化

  • /blog/*/tags/* 加入排除列表
  • 这样访问博客文章时,Cloudflare 会直接返回静态 HTML 文件
  • Worker 不再处理这些请求,避免了 NoFallbackError

步骤 4:更新构建脚本

修改 package.json

{
  "scripts": {
    "pages:build": "npx @opennextjs/cloudflare build && mv .open-next/worker.js .open-next/_worker.js && cp -r .open-next/assets/* .open-next/ && mkdir -p .open-next/blog && cp -r .next/server/app/blog/*.html .open-next/blog/ 2>/dev/null || true && node scripts/fix-routes.mjs && node scripts/create-blog-structure.mjs"
  }
}

步骤 5:重新构建和部署

# 清理旧构建
rm -rf .open-next .next

# 重新构建
npm run pages:build

构建输出:

🔧 Creating directory structure for blog posts...
Created 41 blog post directories
Created 0 tag directories
Directory structure created successfully!

部署:

wrangler pages deploy .open-next --project-name blog-nextjs

验证结果

测试博客文章访问:

# 测试新部署
curl -I https://geekhuashan.com/blog/chai-shu-reading-notes

# 返回:
HTTP/2 200
content-type: text/html; charset=utf-8
cache-control: public, max-age=0, must-revalidate

成功!所有博客文章都能正常访问了!

继续排查:博客列表页和分页也 404

解决了博客文章的 404 问题后,又发现了两个新问题:

问题 1:博客列表页 /blog 返回 404

虽然单独的博客文章可以访问了,但访问博客列表页时又遇到了 404:

curl -I https://geekhuashan.com/blog
# HTTP/2 404 Not Found

诊断过程

检查构建产物:

ls .next/server/app/blog.html
# blog.html  ✅ 列表页文件存在

检查部署目录:

ls .open-next/blog/
# chai-shu-reading-notes/  ✅ 博客文章目录存在
# chai-shu-reading-notes.html
# ... 但是没有 index.html!

根本原因

  • Cloudflare Pages 会将 /blog 映射到 /blog/index.html
  • 但我们只复制了单独的博客文章 HTML 文件
  • 没有复制 blog.html(列表页)作为 blog/index.html

解决方案

在构建脚本中添加复制命令:

cp .next/server/app/blog.html .open-next/blog/index.html

问题 2:分页页面 /blog/page/2 也 404

解决列表页后,测试分页功能时又发现问题:

curl -I https://geekhuashan.com/blog/page/2
# HTTP/2 404 Not Found

诊断过程

检查 Next.js 构建产物:

ls .next/server/app/blog/page/
# 2.html  3.html  ...  ✅ 分页文件存在

检查部署目录:

ls .open-next/blog/page/
# ls: .open-next/blog/page/: No such file or directory  ❌

根本原因:分页目录没有被复制到部署目录。

解决方案

  1. 在构建脚本中添加复制分页目录的命令:
cp -r .next/server/app/blog/page .open-next/blog/
  1. 更新 scripts/create-blog-structure.mjs,让它也处理分页目录:
const blogDir = '.open-next/blog'
const tagsDir = '.open-next/tags'
const blogPageDir = '.open-next/blog/page'  // ✅ 新增分页目录

// ...

try {
  const blogCount = createIndexStructure(blogDir)
  const tagsCount = createIndexStructure(tagsDir)
  const blogPageCount = createIndexStructure(blogPageDir)  // ✅ 处理分页文件

  console.log(`✅ Created ${blogCount || 0} blog post directories`)
  console.log(`✅ Created ${tagsCount || 0} tag directories`)
  console.log(`✅ Created ${blogPageCount || 0} blog page directories`)
  console.log('✨ Directory structure created successfully!')
}

最终的完整构建脚本

更新后的 package.json

{
  "scripts": {
    "pages:build": "npx @opennextjs/cloudflare build && mv .open-next/worker.js .open-next/_worker.js && cp -r .open-next/assets/* .open-next/ && mkdir -p .open-next/blog && cp -r .next/server/app/blog/*.html .open-next/blog/ 2>/dev/null || true && cp .next/server/app/blog.html .open-next/blog/index.html 2>/dev/null || true && cp -r .next/server/app/blog/page .open-next/blog/ 2>/dev/null || true && node scripts/fix-routes.mjs && node scripts/create-blog-structure.mjs"
  }
}

关键步骤分解:

  1. npx @opennextjs/cloudflare build - 构建 Cloudflare 适配的 Next.js 应用
  2. mv .open-next/worker.js .open-next/_worker.js - 重命名 Worker 入口文件
  3. cp -r .open-next/assets/* .open-next/ - 复制静态资源到根目录
  4. mkdir -p .open-next/blog - 创建博客目录
  5. cp -r .next/server/app/blog/*.html .open-next/blog/ - 复制所有博客文章 HTML
  6. cp .next/server/app/blog.html .open-next/blog/index.html - 复制博客列表页
  7. cp -r .next/server/app/blog/page .open-next/blog/ - 复制分页目录
  8. node scripts/fix-routes.mjs - 配置 _routes.json
  9. node scripts/create-blog-structure.mjs - 转换目录结构

再次验证

重新构建和部署后,测试所有场景:

# 测试博客列表页
curl -I https://geekhuashan.com/blog/
# HTTP/2 200  ✅

# 测试自动重定向(不带尾部斜杠)
curl -I https://geekhuashan.com/blog
# HTTP/2 308 (永久重定向到 /blog/)  ✅

# 测试单篇博客文章
curl -I https://geekhuashan.com/blog/chai-shu-reading-notes
# HTTP/2 200  ✅

# 测试分页
curl -I https://geekhuashan.com/blog/page/2
# HTTP/2 200  ✅

所有页面类型都正常工作了!

最终目录结构

.open-next/
├── _worker.js
├── _routes.json
├── _next/
│   └── static/          # CSSJS 等静态资源
├── blog/
│   ├── index.html       # ✅ 博客列表页(/blog)
│   ├── chai-shu-reading-notes/
│   │   └── index.html   # ✅ 博客文章(/blog/chai-shu-reading-notes)
│   ├── chai-shu-reading-notes.html
│   ├── another-post/
│   │   └── index.html
│   ├── another-post.html
│   └── page/            # ✅ 分页目录
│       ├── 2/
│       │   └── index.html  # /blog/page/2
│       ├── 2.html
│       ├── 3/
│       │   └── index.html  # /blog/page/3
│       └── 3.html
└── tags/
    └── ... (同样的结构)

核心要点

  1. NoFallbackError 的根源:Worker 无法访问本地文件系统缓存
  2. 解决思路:将静态页面从 Worker 管辖中排除
  3. 目录结构:使用 Cloudflare Pages 的"漂亮 URL"特性
    • /blog/blog/index.html(列表页)
    • /blog/post-name/blog/post-name/index.html(文章页)
    • /blog/page/2/blog/page/2/index.html(分页)
  4. 完整性检查:不仅要复制文章,还要复制列表页和分页
  5. 自动化:通过脚本自动处理目录结构转换

性能对比

指标Worker 模式静态文件模式
响应时间~100-200ms~10-30ms
计费计入 Worker 请求次数免费(CDN)
可靠性依赖缓存配置100% 可靠
TTFB较慢极快

结论:对于预渲染的博客文章,静态文件模式性能更好、成本更低、配置更简单!

相关资源

总结

将 Next.js 应用部署到 Cloudflare Pages 时,我遇到了三个主要的 404 问题,虽然表现形式不同,但解决思路一致:理解路径映射 + 利用静态文件

问题一:静态资源(CSS/JS)404

根本原因:路径映射不匹配

  • HTML 引用:/_next/static/css/xxx.css
  • 实际位置:.open-next/assets/_next/static/css/xxx.css

解决方案

  1. assets/ 内容复制到根目录:cp -r .open-next/assets/* .open-next/
  2. 配置 _routes.json 排除静态资源:"/_next/static/*"
  3. 让路径映射正确:HTML 引用 → Web 路径 → 文件系统路径

问题二:博客文章页面 404

根本原因:NoFallbackError(Worker 无法访问缓存)

  • OpenNext Worker 尝试从本地文件系统读取缓存失败
  • Cloudflare Pages 环境需要 R2/KV 作为缓存后端

解决方案

  1. 创建目录结构:blog/post-name.htmlblog/post-name/index.html
  2. 配置 _routes.json 排除博客路径:"/blog/*"
  3. 让预渲染页面作为静态文件直接由 CDN 提供

问题三:博客列表页和分页 404

根本原因:遗漏了列表页和分页文件的复制

  • /blog 需要映射到 /blog/index.html(列表页)
  • /blog/page/2 需要映射到 /blog/page/2/index.html(分页)

解决方案

  1. 复制博客列表页:cp blog.html .open-next/blog/index.html
  2. 复制分页目录:cp -r .next/server/app/blog/page .open-next/blog/
  3. 更新自动化脚本,处理分页文件的目录结构转换

核心经验

  1. 理解部署平台的工作机制:Cloudflare Pages 的目录结构、Worker 模式、Pretty URLs
  2. 路径映射是关键:HTML 引用、Web 访问路径、文件系统路径必须一致
  3. 静态优先原则:能用静态文件就不要用 Worker,性能更好、成本更低
  4. 完整性很重要:不仅要处理主要功能(文章页),还要覆盖列表页、分页等边缘情况
  5. 自动化部署流程:用脚本处理重复性工作,避免手动操作出错
  6. 利用平台特性:Cloudflare Pages 的 _routes.json、Pretty URLs 等特性

最终效果

所有静态资源正常加载(CSS、JS、图片等) ✅ 所有博客文章可访问(无 404 错误) ✅ 博客列表页正常显示(包括所有分页) ✅ 性能优化:静态文件由 CDN 直接提供,TTFB < 30ms ✅ 成本优化:静态文件不计入 Worker 请求次数 ✅ 部署自动化:一条命令完成构建和部署

希望这篇详细的故障排查记录能帮助遇到类似问题的开发者快速解决部署难题!


遇到问题了吗? 欢迎在评论区分享你的经验或提问。

觉得有用? 点个赞支持一下!⭐

Next.js 部署到 Cloudflare Pages:静态资源 404 问题完全解决 | 原子比特之间