别让海外访客看到一屏中文:Next.js 多语言站点用 next-intl 搭起来
【免费下载链接】YimMenuV2Experimental menu for GTA 5: Enhanced项目地址: https://gitcode.com/GitHub_Trending/yi/YimMenuV2
一个法国访客带着法语的 Accept-Language 打开了你的首页,看到的却是一整屏中文——或者反过来,他手动刷新了三次,页面死活不肯换语言。多语言站点的麻烦往往不在这页文案怎么翻,而在一堆没人提前想清楚的细节:语言从哪来、URL 里到底要不要挂 /en 前缀、切换语言时页面会不会白屏闪一下、博客正文这种动态内容跟着谁走。
nelsonlai.dev 站点把英语、中文(简繁)、日语、西班牙语、葡萄牙语共 6 种语言跑在同一个 Next.js 项目里,用的是 next-intl。它的方案值得拆开看,因为它几乎在每个容易翻车的地方都做了明确的取舍,而每个取舍背后都有具体的工程理由。
浏览器带了一串语言偏好,页面该听谁的
用户请求进站点时,浏览器基本都会带上 Accept-Language 头,里面是一串带权重的语言列表,比如fr-FR,fr;q=0.9,en;q=0.8。说白了就是访客在告诉服务器:"我最想看法语,其次能接受英语。"
问题是这个头并不总是可靠:有人改过浏览器语言却没改操作系统语言,有人挂着代理,有人就是偏好和你提供的不匹配。所以"听谁的"要先定规则,而不是让某个组件临时发挥。
用一个 routing 对象集中声明语言
采用 next-intl 而不是 react-i18next,主要原因是它和 App Router 的路由、中间件、metadata 是同构的——语言不是挂在某个 Context 上的"附加品",而是路由本身的一部分。所有语言相关的决策因此可以收敛到一个配置对象里,在 i18n/routing.ts 声明:
export const routing = defineRouting({ locales: ['en', 'zh-TW', 'zh-CN', 'ja', 'es', 'pt-BR'] as const, defaultLocale: 'en', localePrefix: 'as-needed', localeDetection: true, })这四个字段就决定了整站的语言行为,后面所有问题都能从它们推出来。
检测只在入口做一次
middleware 里创建一次 i18n 中间件,让"按 Accept-Language 选语言"这件事只发生在请求入口,而不是散落在每个组件里:
import createMiddleware from 'next-intl/middleware' import { routing } from './routing' export const i18nMiddleware = createMiddleware(routing)检测逻辑放在 i18n/middleware.ts 这一层还有个好处:它跑在 Node 侧,读请求头没有浏览器兼容问题,而且检测结果是"路由决策",天然可测试——给定某个 Accept-Language,能确定性地推出应该落到哪个路径。
URL 里的 /en 前缀,到底要不要
localePrefix有三个常见取值,对应三种完全不同的 URL 策略,这个选择会一直影响你。
- always:所有页面都带前缀,
/en/about、/ja/about,最直观,但默认语言用户也要忍受一个多余的前缀。 - never:URL 里完全没有语言,只靠 Accept-Language 和 Cookie 区分。看起来干净,但链接不可移植——别人把
/about发给你,他看到的可能是日版,你看到的是英版。 - as-needed:本项目采用,只对"非默认语言"加前缀。英语用户直接访问
/about,日语用户访问/ja/about。
为什么前缀 URL 值得多占几个字符
这里有个坑:只靠 Accept-Language 区分语言,对搜索引擎是灾难。hreflang 标签要求每种语言有一个可独立索引的规范 URL;带前缀的 URL 才能稳定地承载它,也才能在邮件里被分享而不失真。前缀不是冗余,它是"这个页面是这个语言版本"的自证。
localePrefix: 'as-needed'同时保住了两边:默认语言 URL 最干净,非默认语言依然有独立可分享的地址。对以英语为默认、其他语言为增量的站点,这是最省事的折中。
换语言时,怎么不闪一下
切换语言最反直觉的地方在于:用户期待的是"同一页换种语言",而不是"被扔回首页"。实现上就两件事——记住当前路径,切换 locale 后回到同样的路径。
路径与 locale 分离
切换器组件 locale-switcher.tsx 用usePathname()拿到当前(已去前缀的)路径,再交给 router:
function handleLocaleChange(value: Locale | null) { if (!value) return router.replace(pathname, { locale: value }) }replace而不是push,是为了不在浏览器历史里留下一堆语言切换记录——用户按后退键时不会在英版和日版之间来回弹跳。
触发器别偷懒
选择器套了一层带aria-label的 SelectTrigger,让屏幕阅读器用户也能换语言。这类细节平时不起眼,但在"多语言=服务更多人群"的语境下,无障碍不是锦上添花,而是多语言体验的一部分。
博客正文这种动态内容,跟着谁走
翻译文件解决的是"界面文案",但站点真正的内容是博客正文。把所有正文逐句翻进 messages 是不可维护的:正文会持续增长,翻译产能跟不上,最后一定出现半英半中的页面。
按语言过滤,而不是按语言翻译
正确姿势是把"属于哪种语言"作为内容的一个属性,而不是内容的副本。文章元数据带上 locale 字段,列表页按当前 locale 过滤:
export function getAllPostsByLocale(locale: string) { return allPosts.filter((post) => post.locale === locale) }这带来一个自然的推论:一篇法语读者看不到的文章,不会出现在法语文档列表里,也不会贡献一个 404 的 /fr 链接。内容缺席是显式的,而不是翻译缺失造成的隐性空洞。
文案与内容的分界线
一个实用的判据:界面里所有"按钮、标签、提示"走 messages 的翻译键;所有"用户或作者产出的正文"走 locale 字段过滤。这条线一划,翻译维护者只需要盯前者,内容作者只管写后者。
日期、复数和元数据:容易被忽略的细节
多语言真正出丑的地方,往往不是主文案,而是这些边角。
日期别自己拼字符串
"July 4, 2026"、"2026年7月4日"、"4 juillet 2026"——月份顺序、分隔符、"日"字位置全都不一样。让useFormatter的dateTime去处理,它内部走的是语言环境对应的格式化规则,不要手写:
const format = useFormatter() format.dateTime(new Date(), { dateStyle: 'medium' })复数规则写在哪一层
英语有单复数,俄语有六种,日语没有。把复数写进模板字符串(1 like/n likes)等于把语言学规则硬编码进 UI。next-intl 的翻译键本身带 ICU 复数处理,t('common.likes', { count: 5 })在不同语言下自动落到正确的形态,维护者只需要在每种语言的 messages 里各写一次复数分支。
每种语言都要有规范元数据
SEO 层面,多语言站的 metadata 要为每种语言生成独立的alternates:canonical 指向自身语言版本,languages里列出所有兄弟语言,x-default指回默认语言。这套标签是告诉搜索引擎"这几条 URL 是同一页的不同语言"的唯一方式,缺了它,多语言就只是多了一份内容,而不是多了一种被正确索引的语言。
语言列表怎么克制,结构怎么留
这套方案里最容易被低估的,反而是两处"不做什么"。
语言数量是成本问题,不是配置问题
每加一种语言,成本不只是翻一遍 messages:所有复数分支、所有带 HTML 的翻译、所有动态插值的键都要有人核对,而且这个成本随功能增长持续累积。6 种语言能维持得下去,很大程度上是因为加语言前算过这笔账。语言列表是产品决策,不是往 locales 数组里塞字符串。
结构镜像内容
messages 的组织刻意贴合站点信息架构:homepage.hero、common.save这样的键,基本能照着页面结构读出来。新增一个页面时,翻它对应的键段就能判断还缺什么,而不是全局搜一遍。反过来,一旦有人为了"复用"把键抽到离页面很远的通用命名空间里,维护成本会指数上升——宁可重复,也别让翻译者猜键在哪。
想把它落下去,最实际的第一步只有一个:先确定 defaultLocale 和 localePrefix 这两个值,再用 Accept-Language 实测一次自动检测是否符合预期。这两个决定会决定你之后所有语言相关代码的形状,值得在写任何页面之前就先钉死。🌍
【免费下载链接】YimMenuV2Experimental menu for GTA 5: Enhanced项目地址: https://gitcode.com/GitHub_Trending/yi/YimMenuV2
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考