Next.js 15 + Cloudflare Workers 实战:零中间件多语言 SEO 站点架构

6 阅读4分钟

​

 从 0 到 1 搭建一个支持 9 种语言、部署在 Cloudflare Workers 上的游戏攻略网站,核心思路:用文件结构替代中间件,把 Worker 成本砍到最低。

一、项目背景

最近做了一个游戏的攻略站点,需要:

  • 支持 9 种语言(英/日/中/西/法/德/韩/意/葡)
  • SEO 友好(hreflang、canonical、sitemap、JSON-LD 全套)
  • 部署在 Cloudflare Workers 免费套餐(10 万请求/天)
  • 尽可能降低 Worker 调用次数

二、技术栈

层选型
框架Next.js 15 (App Router, RSC)
国际化next-intl v4
样式Tailwind CSS
部署Cloudflare Workers (OpenNext)
分析Google Analytics 4

三、核心架构:零中间件多语言

1. 问题:中间件的代价

传统的 next-intl 方案用中间件检测语言然后重定向。但在 Cloudflare Workers 上,每次重定向 = 2 次 Worker 调用(初始请求 + 重定向目标)。免费套餐每天只有 10 万次,重定向直接吃掉一半配额。

2. 方案:用路由组(Route Group)替代中间件

删掉 middleware.ts,用 Next.js App Router 的路由组天然隔离语言:

src/app/
├── layout.tsx              ← 根布局:HTML 骨架 + GA + JSON-LD
├── (root)/                 ← 英文路由组(URL 无前缀)
│   ├── layout.tsx          ← LocaleShell(defaultMessages=en)
│   ├── page.tsx            → /
│   ├── about/page.tsx      → /about
│   └── gameplay/page.tsx   → /gameplay
└── [locale]/               ← 多语言路由组(URL 有前缀)
    ├── layout.tsx          ← LocaleShell(defaultMessages=对应语言)
    ├── page.tsx            → /zh, /ja, /es ...
    ├── about/page.tsx      → /zh/about
    └── gameplay/page.tsx   → /zh/gameplay

关键点:

  • (root) 是 Next.js 的路由组语法,不会出现在 URL 中。(root)/about/page.tsx 对应 /about,不是 /(root)/about。
  • 两个路由组互斥——一个 URL 要么匹配 (root)(英文),要么匹配 [locale](其他语言),永远不会同时匹配。
  • 英文没有前缀(/about),其他语言带前缀(/zh/about),干净直观。

3. 为什么不用 localePrefix: 'always'?

如果所有语言都带前缀(/en/about、/zh/about),可以用中间件统一处理。但英文作为默认语言不带前缀是 SEO 最佳实践——Google 推荐 x-default 指向无前缀版本。

四、两个 LocaleShell 的设计

每个页面都有两个 page.tsx——一个在 (root)/,一个在 [locale]/。它们共享同一个 View 组件,只是获取语言的方式不同:

// (root)/about/page.tsx — 英文,硬编码
export function generateMetadata() {
  return getMetadata('en');
}
export default function AboutPage() {
  setRequestLocale('en');
  return <AboutView />;
}

// [locale]/about/page.tsx — 多语言,从 URL 读取
export async function generateMetadata({ params }) {
  const { locale } = await params;
  return getMetadata(locale);
}
export default async function AboutPage({ params }) {
  const { locale } = await params;
  setRequestLocale(locale);
  return <AboutView />;
}

代价:9 个页面 × 2 = 18 个 page.tsx 文件。但换来的是零中间件、零重定向、每次请求只消耗 1 次 Worker 调用。

五、SEO 全套配置

1. hreflang 标签

在 src/lib/seo.ts 中统一生成,每个页面的 generateMetadata() 返回值自动包含 alternates,Next.js 会在 <head> 中渲染对应的 <link rel="alternate"> 标签。

2. JSON-LD 结构化数据

  • 全站:WebSite + VideoGame(根布局)
  • 每个页面:BreadcrumbList
  • /faq:FAQPage
  • /release-date:Article

3. Sitemap

生成 9 语言 × N 页面 的完整 sitemap,每个 URL 包含 priority 和 alternates。

六、缓存策略

Cache-Control: public, s-maxage=86400, stale-while-revalidate=604800

  • s-maxage=86400:Cloudflare 边缘缓存 HTML 24 小时
  • stale-while-revalidate=604800:过期后 7 天内先返回旧内容,后台重新验证
  • 不设置 max-age:浏览器不缓存,确保语言切换即时生效

静态资源(_next/static/*、SVG、robots.txt)通过 wrangler.jsonc 的 assets binding 直接由 Cloudflare Static Assets 提供,完全不经过 Worker,零成本。

七、部署

使用 @opennextjs/cloudflare 把 Next.js 打包成 Cloudflare Worker:

npm run dev        # 本地开发
npm run deploy     # 部署到 Cloudflare Workers

wrangler.jsonc 关键配置:

  • main:.open-next/worker.js
  • assets.directory:.open-next/assets(免费,不经过 Worker)
  • compatibility_date:保持运行时更新

八、踩坑记录

1. getMessages() 返回错误语言

在 [locale]/layout.tsx 中使用 getMessages() 时,即使调用了 setRequestLocale('zh'),返回的仍然是英文消息。

解决:改用显式动态导入:

const messages = (await import(`@/messages/${locale}.json`)).default;

2. 不要在根布局调用 setRequestLocale

根布局中的 setRequestLocale('en') 会污染上下文,导致 [locale] 子路由也收到 'en'。

解决:只在 (root)/ 和 [locale]/ 的 layout 中分别设置。

3. max-age vs s-maxage

如果用 max-age,浏览器会缓存 HTML,导致用户切换语言后看到的还是旧语言的页面。

解决:只用 s-maxage,让 CDN 缓存,浏览器每次都请求 CDN。

九、总结

传统方案本方案
语言检测中间件检测 → 重定向路由组天然隔离
Worker 调用每次可能 2 次永远 1 次
状态依赖需要 cookie / Accept-Language纯 URL 决定语言
文件结构单套 page.tsx + 动态路由双套 page.tsx(文件多但逻辑简单)

核心取舍:用文件数量换 Worker 成本和架构简洁性。对于内容站点(页面数量有限、不经常变动),这个取舍非常值得。

项目链接:The Duskbloods — Release Date, Gameplay & Network Test Guide | Duskbloods Guide

技术栈:Next.js 15 · next-intl · Tailwind CSS · Cloudflare Workers

​