国际化路由
语言放进 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 库是过度工程