跳到主要内容

next-intl 进阶用法与多语言 Key 类型约束

如果一个 Next.js 项目只是“能切语言”,那通常只需要把文案放进消息文件里,再在组件里调用 t('xxx')

但项目一旦重新把 next-intl 当成正式方案来用,关注点很快就会变:

  • 不只是翻译字符串,还要处理路由、服务端取词、Metadata、多语言 SEO
  • 不只是 t('title'),还会用到 t.rich(...)t.raw(...)、ICU 插值、select、复数、日期和数字格式化
  • 不只是“有没有文案”,还会开始关心 key 是否写错、参数是否漏传、locale 是否受控
  • 再往后,团队往往会想把部分业务配置也绑到类型系统上,比如给“允许传入的多语言 key”再包一层业务类型

这篇就专门讲这一层。

为什么现在很多 App Router 项目会重新选 next-intl

如果你已经在用 App Router,多语言方案最怕的通常是两件事:

  • 路由、服务端组件、客户端组件各写各的,最后散掉
  • 类型没有收住,项目越大越容易出现 key 拼错、参数漏传、locale 混乱

next-intl 现在比较适合 App Router 项目的地方,是它把几条常见主线接得比较顺:

  • getRequestConfig 管理一次请求范围内的国际化配置
  • defineRoutingcreateNavigation 把 locale 路由和导航 API 收到一起
  • 同时覆盖 Server Components、Client Components、Metadata、Server Actions 等运行位置
  • 可以通过 TypeScript augmentation,把 LocaleMessagesFormats 都约束起来

所以它不只是解决“翻译字符串”,而是把 Next.js 里的国际化运行时和类型层一起收住。

一套比较稳的基础接线

如果项目是 App Router,并且希望 locale 进入 URL,一般会把结构收成下面这几层。

1. routing.ts 作为路由中心

import {defineRouting} from 'next-intl/routing';

export const routing = defineRouting({
locales: ['zh-CN', 'en'] as const,
defaultLocale: 'zh-CN'
});

它的意义不是“少写一个配置文件”,而是把下面几类信息统一到一个源头:

  • 支持哪些 locale
  • 默认 locale 是谁
  • URL 前缀策略是什么
  • 路径是否需要按语言做本地化
  • domain routing、cookie、locale detection 要不要启用

项目做大以后,这个“单一入口”很重要。否则 locale 列表会散落在中间件、路由跳转、表单校验和类型声明里。

2. navigation.ts 包一层 next-intl 导航 API

import {createNavigation} from 'next-intl/navigation';
import {routing} from './routing';

export const {Link, redirect, usePathname, useRouter, getPathname} =
createNavigation(routing);

这样做之后,项目里尽量统一从自己的 i18n/navigation 导出里拿导航能力,而不是到处混用原生 next/navigation 和自己拼 locale。

好处很直接:

  • 语言切换时不会漏掉 locale
  • pathname 做本地化时,调用层基本不用重写
  • 后面如果改 localePrefixpathnames,受影响面更小

3. request.ts 负责请求级配置

import {getRequestConfig} from 'next-intl/server';
import {hasLocale} from 'next-intl';
import {routing} from './routing';

export default getRequestConfig(async ({requestLocale}) => {
const requested = await requestLocale;
const locale = hasLocale(routing.locales, requested)
? requested
: routing.defaultLocale;

return {
locale,
messages: (await import(`../../messages/${locale}.json`)).default
};
});

这层是整个 next-intl 的“总闸门”。

这里通常会放:

  • locale 校验和兜底
  • messages 加载
  • 全局 formats
  • nowtimeZone
  • onErrorgetMessageFallback

如果项目后面要接 CMS、远程词条服务,或者做按租户加载词条,通常也是从这层扩展,而不是去改每个页面。

4. [locale] 段作为路由承载点

import {NextIntlClientProvider, hasLocale} from 'next-intl';
import {notFound} from 'next/navigation';
import {setRequestLocale} from 'next-intl/server';
import {routing} from '@/i18n/routing';

export default async function LocaleLayout({
children,
params
}: {
children: React.ReactNode;
params: Promise<{locale: string}>;
}) {
const {locale} = await params;

if (!hasLocale(routing.locales, locale)) {
notFound();
}

setRequestLocale(locale);

return (
<html lang={locale}>
<body>
<NextIntlClientProvider>{children}</NextIntlClientProvider>
</body>
</html>
);
}

这一步做了三件关键事情:

  • 校验传入 locale 是否合法
  • setRequestLocale(locale) 给当前请求绑定 locale
  • NextIntlClientProvider 让客户端组件也能接到配置

如果你需要静态渲染,还要记得配合 generateStaticParamsnext-intl 官方文档里也特别强调了:setRequestLocale 要在调用 useTranslationsgetMessages 这类 API 之前执行。

常见高级用法

真正让 next-intl 和“拿 JSON 查表”这类轻方案拉开差距的,往往不是基础接线,而是下面这些能力。

1. 服务端和客户端都能优雅取词

同步组件里常见的是:

import {useTranslations} from 'next-intl';

export default function Profile() {
const t = useTranslations('Profile');
return <h1>{t('title')}</h1>;
}

异步组件、generateMetadata、Server Actions 这类位置,则更适合:

import {getTranslations} from 'next-intl/server';

const t = await getTranslations({locale, namespace: 'Metadata'});

这会让“服务端场景也要多语言”变成一件自然的事,而不是把文案逻辑硬塞回客户端。

2. t.rich(...) 处理富文本

很多项目重新上 next-intl 后,用得最多的高级能力往往就是 t.rich(...)

{
"hero": "阅读 <link>完整文档</link> 了解更多。"
}
t.rich('hero', {
link: (chunks) => <a href="/docs">{chunks}</a>
});

这类写法适合:

  • 一段文案里夹一个链接
  • 带强调、加粗、斜体的营销文案
  • 可复用的富文本标签体系

实际项目里,更省心的做法通常是把常用标签抽成 RichText 组件或 tags 工具,而不是每个页面都手写一遍 <b><a><p> 的映射。

3. t.raw(...) 适合拿结构化内容

这个特性不算最常提,但有些场景挺顺手。

{
"seo": {
"keywords": ["next-intl", "App Router", "i18n"]
}
}
const keywords = t.raw('seo.keywords') as string[];

t.raw(...) 适合拿这类“不想先格式化成字符串”的值,比如:

  • 数组
  • 对象
  • 结构化配置
  • 想直接交给别的函数消费的原始数据

但它也有一个边界:既然拿的是原始值,就不会像 t()t.rich() 那样帮你做消息格式化。一般更适合配置数据,而不是最终要渲染给用户看的正文。

4. ICU 插值、复数、选择器

next-intl 的消息层不只是“替换变量”,而是完整走 ICU Message 这一套。

{
"greeting": "你好,{name}",
"followers": "你有 {count, plural, =0 {0 个粉丝} =1 {1 个粉丝} other {# 个粉丝}}",
"membership": "{role, select, admin {管理员} editor {编辑} other {普通用户}}"
}
t('greeting', {name: user.name});
t('followers', {count: stats.followers});
t('membership', {role: user.role});

select 这个点很实用。它适合把“根据输入值切换不同文案”的逻辑收回消息层,而不是在组件里写一串 if/else 或三元表达式。

常见场景有:

  • 用户角色
  • 订单状态
  • 支付方式
  • 渠道来源

再加上 pluralselectordinal 这些能力,很多原来散在业务代码里的文案判断都可以收回消息层。尤其是:

  • 复数
  • select
  • ordinal
  • 数字和日期嵌入文案

项目国际化做深一点,这一层几乎迟早都会用到。

5. 数字、日期、相对时间格式化

如果值本身不是一段消息,而是“独立的格式化值”,更适合用 useFormatter()

import {useFormatter, useNow} from 'next-intl';

function PriceAndTime() {
const format = useFormatter();
const now = useNow({updateInterval: 1000 * 10});

return (
<>
<span>{format.number(499.9, {style: 'currency', currency: 'USD'})}</span>
<span>{format.relativeTime(new Date('2026-05-30T08:00:00.000Z'), now)}</span>
</>
);
}

这类能力很容易被忽略,但也最容易露出“多语言只做了一半”的问题:

  • 中文和英文的日期展示差异
  • 货币符号位置差异
  • 千分位、百分比、小数精度差异
  • “2 hours ago” 这种相对时间差异

如果项目已经用了 next-intl,这部分最好直接走它,不要再和 dayjs 文案拼接、手写 toLocaleString() 混着来。

6. Metadata、Server Actions、OG 图这些非组件位置

这是 next-intl 在 App Router 下非常实用的一点。

比如 generateMetadata

import {getTranslations} from 'next-intl/server';

export async function generateMetadata({params}: {params: Promise<{locale: string}>}) {
const {locale} = await params;
const t = await getTranslations({locale, namespace: 'Metadata'});

return {
title: t('title'),
description: t('description')
};
}

Server Action 里返回用户可见错误文案,也可以直接本地化:

import {getTranslations} from 'next-intl/server';

export async function loginAction(formData: FormData) {
'use server';

const t = await getTranslations('LoginForm');

if (!isValid(formData)) {
return {error: t('invalidCredentials')};
}
}

这类位置如果不统一处理,最后常见的结果就是:

  • 页面正文是多语言的
  • SEO title 还是写死的
  • 表单错误提示还是英文
  • Open Graph 图文案没有跟着 locale 走

路由层的几个进阶配置

如果项目真的把多语言当成正式能力,而不是 demo,路由配置一般还会继续往下细化。

localePrefix

常见三种:

  • always/en/about
  • as-needed:默认语言不带前缀,其他语言带
  • never:URL 不展示 locale,内部仍通过 [locale] 承载

经验上:

  • 内容站、SEO 站点,默认优先考虑 alwaysas-needed
  • 强用户态应用,且 locale 更像“个人偏好”时,never 也可能成立

pathnames

如果你希望路径本身也本地化,可以直接在 routing.ts 里做映射:

pathnames: {
'/about': {
de: '/uber-uns'
},
'/services': {
de: '/leistungen'
}
}

这比“页面里手动判断当前语言然后跳不同路径”要干净很多。它本质上是:

  • 外部 URL 可以按语言变化
  • 内部文件系统路由保持稳定

这对 SEO、多语言站点结构、CMS 驱动 slug 都很有帮助。

domainslocaleDetectionlocaleCookie

这三个配置更像“正式国际化项目”的工程选项:

  • domains:不同域名承载不同 locale
  • localeDetection:是否根据请求头和 cookie 自动判定语言
  • localeCookie:是否记住用户最近一次选择的 locale

这几个点很少是“语法题”,更多是产品和 SEO 策略题。技术上都能配,是否值得配,要看业务。

类型增强:先把 next-intl 官方提供的类型能力用起来

在自己封装业务级 key 类型之前,更值得先把 next-intl 原生的 TypeScript augmentation 用起来。

核心入口通常是一个全局声明文件,比如 global.ts

import {routing} from '@/i18n/routing';
import {formats} from '@/i18n/request';
import messages from './messages/zh-CN.json';

declare module 'next-intl' {
interface AppConfig {
Locale: (typeof routing.locales)[number];
Messages: typeof messages;
Formats: typeof formats;
}
}

这三项分别解决三类问题。

1. Locale:把 locale 收成受控联合类型

这样以后:

  • useLocale() 返回值不再只是宽泛的 string
  • <Link locale="..."> 会有约束
  • 你自己定义接受 locale 的函数,也可以直接复用 next-intl 提供的 Locale 类型

这一层看起来不花哨,但很值。因为 locale 一旦不受控,很多下游逻辑都会被迫退回 string

2. Messages:让 t() 的 key 真正受约束

这往往是最先见效的一层。

配上之后:

  • useTranslations('Profile') 的 namespace 会校验
  • t('title') 的 key 会校验
  • 写错 key 时,问题会在编译阶段暴露

这对大型项目很重要,因为多语言问题通常不是“语法报错”,而是上线后某个页面静默丢词。

3. 类型化参数:让插值参数也受约束

如果消息是:

{
"title": "你好,{firstName}"
}

那理想状态下,t('title') 不传 firstName 就应该报错。

next-intl 官方现在提供了 createMessagesDeclaration 这条路,给消息 JSON 生成 .d.json.ts 声明文件,来补上 JSON 值类型推断过宽的问题。

这意味着多语言类型系统不只校验“key 对不对”,还能继续校验“参数齐不齐”。

在官方能力之上,再封装业务级 key 类型

很多团队做到这里还不够,还会继续加一层业务限制。

典型诉求是:

  • 并不是所有页面都应该能拿任意 namespace
  • 某些配置项只能引用指定范围内的多语言 key
  • 某些业务模块希望把“可用 key”收成一个更语义化的类型

这时才值得引入自己的 I18nKey 一类封装。

一种常见的封装方式

先基于消息结构,抽象一个“命名空间 + key 路径”的泛型。

type I18nMessages = typeof import('../../messages/zh-CN.json');

type Namespace = keyof I18nMessages & string;

type I18nKey<TNamespace extends Namespace> = `${TNamespace}.${string}`;

上面只是一个起点。再往下,你可以继续把第二段路径也收窄,而不是停在 ${string}

type MarketingKey = I18nKey<'Marketing'>;
type AccountKey = I18nKey<'Account'>;

然后在业务配置里这样用:

type FeatureCard = {
titleKey: MarketingKey;
descriptionKey: MarketingKey;
};

这样配置型数据就不会再随便塞进:

  • 任意裸字符串
  • 其他 namespace 的 key
  • 拼错但编译不报错的文案路径

什么时候值得自己封装

下面几类场景通常值得:

  • 页面配置、菜单配置、表单 schema、卡片定义这类“配置驱动 UI”
  • 同一个模块需要反复引用一组稳定 key
  • 想把 key 作为函数参数或 DTO 字段传递

什么时候不要过度封装

下面几类场景就别太激进:

  • 只是页面内部偶尔写一个 t('title')
  • 文案结构还在频繁调整
  • key 动态拼接特别多,暂时收不住
  • 团队对复杂 TS 泛型维护成本比较敏感

换句话说,业务级 key 类型最适合“稳定配置面”,不适合所有地方一股脑铺开。

一个更实用的分层思路

如果你想把这套东西做得既稳又不重,可以按三层理解。

第一层:官方原生能力

  • useTranslations
  • getTranslations
  • useFormatter
  • defineRouting
  • createNavigation
  • AppConfig augmentation

这层应该优先吃满。

第二层:项目级封装

  • i18n/routing.ts
  • i18n/navigation.ts
  • i18n/request.ts
  • messages 加载约定
  • RichText 公共标签

这层解决的是“别让 next-intl 在项目里散掉”。

第三层:业务级类型封装

  • I18nKey<TNamespace>
  • MarketingI18nKey
  • AccountI18nKey
  • 配置对象里的 titleKey / labelKey / descriptionKey

这层解决的是“哪些业务面需要更强的 key 约束”。

顺序最好不要反过来。前两层还没整理好,就急着把第三层写得很复杂,最后多半只会让类型系统变成负担。

一些很容易踩到的点

1. 动态拼接 key 要克制

比如:

t(`${section}.${field}.label`);

这种写法一多,类型系统就很难真正帮上忙。

更好的方向通常是:

  • 把动态 key 收成受控映射
  • 把配置抽成 definition
  • 在配置层用受限类型,渲染时只消费

也就是说,尽量让“动态”发生在受控配置里,而不是散在 JSX 中间。

2. 不要把所有 fallback 都交给运行时

getMessageFallbackonError 很有用,但它们更适合兜底和监控,而不是长期替代缺词治理。

更稳的思路通常是:

  • 开发期尽量严格暴露缺词
  • 生产期允许优雅降级
  • 结合日志或错误上报追踪缺词

3. NextIntlClientProvider 的继承规则要搞清楚

它会继承上层 provider 的配置,但 onErrorgetMessageFallback 这两个函数型配置不会自动跨到客户端,需要额外用一个 client provider 去补。

这个点在做统一错误处理时很容易忽略。

4. 消息结构不要一开始就扁平化到失控

next-intl 的 key 用 . 表达嵌套路径,所以消息结构更适合按 namespace 和模块组织,而不是所有 key 全拍平在一层。

项目越大,这个差异越明显:

  • namespace 清楚,useTranslations('Checkout') 会很好用
  • 如果全是平铺 key,维护和复用都很快失去边界

推荐的落地顺序

如果项目正在“重新启用 next-intl”,我更建议按下面顺序推进:

  1. 先把 routing.tsnavigation.tsrequest.ts[locale] 这一套骨架立稳
  2. 再把 useTranslations / getTranslations / useFormatter 的使用边界理顺
  3. 接着补 AppConfig augmentation,把 LocaleMessagesFormats 先约束起来
  4. 最后只在配置驱动、复用频繁的业务面,再加自己的 I18nKey 一类封装

这个顺序的好处是每一步都能单独成立,不容易一上来就把类型系统做重。

一句话总结

重新使用 next-intl,真正值得重视的不是“把文案搬进 JSON”。

更关键的是把下面几件事一起做好:

  • next-intl 把 App Router 下的路由、服务端和客户端取词串起来
  • 用 ICU、t.rich(...)t.raw(...)、格式化 API 把多语言能力补完整
  • AppConfig augmentation 先收住 locale、messages、formats
  • 再在稳定的业务配置面上,按需加一层自己的 I18nKey 类型约束

这样项目得到的就不只是“能翻译”,而是一套更稳的国际化工程结构。

参考资料