国际化路由

语言放进 URL 第一段:[lang] 动态段 + 字典模块 + generateStaticParams 预生成,非法语言走 notFound();再对照 middleware 重定向与子域名方案,划清「什么时候别自己拼」

活演示:zh / en 各一份预渲染页面

切换语言只换 URL 第一段;标题(<title>)也随语言变 —— generateMetadata 与页面读同一份字典

  • generateStaticParams在构建期预生成 zh / en 两份静态页面;语言是路由参数,天然可静态化
  • 字典就是按 key 查表的普通对象topics/router/i18n/dictionaries.ts;page 与 generateMetadata 共用同一份,正文与 <title> 不会语言错位
  • 非法 lang(如 fr)不是错误而是「没有这条」:页面里notFound(),由 not-found.tsx 兜底;语义与动态路由里不存在的 id 一致
  • cacheComponents 约束:非预生成的 lang 在请求时渲染,params 的 await 必须包在 Suspense 里 —— 演示页的 Page 只传 promise,内层 Content 才取值

机制拆解:目录、字典与切换链接

演示规模刻意小(一页 8 个词条),重点全在机制

app/router/i18n/
├── page.tsx                ← 本专题页(讲机制)
└── [lang]/
    └── page.tsx            ← 双语演示页:/router/i18n/zh | /en

topics/router/i18n/
└── dictionaries.ts         ← zh / en 各一份词条,page 与 generateMetadata 共用
// [lang]/page.tsx(节选)—— 演示站只有一页,直接拼目标段
<Link href={`/router/i18n/${other}`}>{dict.switchLabel}</Link>

// 真实站点有多级路径时,通用写法是「换第一段、保留其余」:
//   const segments = usePathname().split("/");
//   segments[1] = other;                 // ["", "zh", "docs", "x"] → ["", "en", "docs", "x"]
//   router.push(segments.join("/"));
// 要点:语言是路由状态,不是组件 state —— 可分享、可收藏、可预渲染

三种方案对照:段级 vs middleware vs 子域名

不是互斥选项:middleware 常与段级搭配,负责「进来时落到语言段上」

方案URL 形态SEO / 分享适用
段级 [lang](本站演示)/zh/docs, /en/docs每种语言一条可索引 URL;hreflang 友好;可静态预生成内容站、文档站 —— 多数场景的默认解
middleware 重定向/docs → 302 → /zh/docs裸路径按 Accept-Language 落地;只解决「第一次进哪个语言」与段级搭配的入口协商层,不单独成方案
子域名zh.example.com每语言独立站点权重;跨域 cookie/会话要自己处理区域化运营(独立团队/合规/结算),不是翻译需求
// middleware.ts —— 无语言前缀的请求,按 Accept-Language 重定向
export function middleware(request: NextRequest) {
    const { pathname } = request.nextUrl;
    if (pathname.startsWith("/zh") || pathname.startsWith("/en")) {
        return;                          // 已有语言段,放行
    }
    const lang = negotiate(request.headers.get("accept-language"));
    return NextResponse.redirect(new URL(`/${lang}${pathname}`, request.url));
}

// 与段级方案不冲突:middleware 只负责「进来时落到一个语言段上」,
// 落地之后走的仍是 [lang] 动态段那套。两者常一起用。

什么时候别自己拼

演示可以手搓,生产内容站请用库

  • 内容驱动的站别手搓:词条一上规模,你需要 ICU MessageFormat 复数规则、插值类型安全、翻译缺失回退、语言包按需加载 —— 这些 next-intl / react-i18next 已经解决,本站演示字典只覆盖「查表」这一格
  • 内容在 CMS 里时,语言是数据不是字典:文章级多语言 应在 CMS 建模(locale 字段 + 关联),[lang] 段只做路由;别把正文搬进代码仓库
  • 别把语言藏进 cookie/state:URL 里没有语言,链接就不可分享、 不可索引,也失去静态预生成的资格 —— 语言必须是路由状态
  • 只有两三种语言的演示站:本站这套(字典 + generateStaticParams) 就够,引入完整 i18n 库是过度工程