react-intl 与 next-i18next 选型实战
High Contrast
Dark Mode
Light Mode
Sepia
Forest
3 min read512 words

react-intl 与 next-i18next 选型实战

核心问题:React 生态里有几个主流 i18n 框架,什么场景选哪个?Next.js App Router 怎么配置多语言?


真实场景

你在构建一个 Next.js 电商站,需要支持中、英、日三种语言。在搜索后发现了 react-intlnext-i18nextnext-intl 几个选项,不知道该选哪个,以及怎么在 App Router 里配置。


React i18n 框架全景对比

graph LR subgraph FormatJS["FormatJS 家族"] RI["react-intl\n(FormatJS 官方)"] NI["next-intl\n(App Router 原生支持)"] end subgraph i18next["i18next 家族"] I18N["i18next\n(核心)"] RI18N["react-i18next\n(React 绑定)"] NI18N["next-i18next\n(Pages Router)"] end
特性 react-intl next-i18next next-intl
标准 ICU 消息格式 i18next 格式 ICU 消息格式
Next.js Pages Router ✅ 专为此设计
Next.js App Router ✅ 手动配置 ⚠️ 支持有限 ✅ 原生支持
复数/选择支持 ✅ 内置 ICU ✅ i18next 插件 ✅ 内置 ICU
SSR 支持
Bundle 大小 中等 较大 较小
TypeScript ✅ 类型安全
翻译文件格式 JSON JSON JSON
社区生态 成熟 最广 快速增长

选型建议: - Next.js App Router(新项目)→ next-intl - Next.js Pages Router(存量项目)→ next-i18next - 纯 React(非 Next.js)→ react-intlreact-i18next


方案 A:next-intl(App Router 推荐)

安装

npm install next-intl

目录结构

├── messages/
│   ├── zh-CN.json
│   ├── en-US.json
│   └── ja-JP.json
├── src/
│   ├── i18n/
│   │   ├── routing.ts      # locale 路由配置
│   │   └── request.ts      # 服务端 locale 获取
│   ├── middleware.ts        # locale 中间件
│   └── app/
│       └── [locale]/       # locale 动态路由
│           ├── layout.tsx
│           └── page.tsx
└── next.config.ts

配置步骤

Step 1:路由配置

// src/i18n/routing.ts
import { defineRouting } from 'next-intl/routing';
export const routing = defineRouting({
locales: ['zh-CN', 'en-US', 'ja-JP'],
defaultLocale: 'zh-CN',
// 路径前缀策略
// 'always':所有 locale 都加前缀 /zh-CN/about
// 'as-needed':默认 locale 不加前缀
localePrefix: 'always',
});

Step 2:中间件

// src/middleware.ts
import createMiddleware from 'next-intl/middleware';
import { routing } from './i18n/routing';
export default createMiddleware(routing);
export const config = {
// 只对非静态文件和 API 路由执行中间件
matcher: ['/((?!api|_next/static|_next/image|favicon.ico).*)'],
};

Step 3:服务端 locale 获取

// src/i18n/request.ts
import { getRequestConfig } from 'next-intl/server';
import { routing } from './routing';
export default getRequestConfig(async ({ requestLocale }) => {
let locale = await requestLocale;
// 确保 locale 有效
if (!locale || !routing.locales.includes(locale as any)) {
locale = routing.defaultLocale;
}
return {
locale,
messages: (await import(`../../messages/${locale}.json`)).default,
};
});

Step 4:next.config.ts

// next.config.ts
import createNextIntlPlugin from 'next-intl/plugin';
const withNextIntl = createNextIntlPlugin('./src/i18n/request.ts');
export default withNextIntl({
// 其他 Next.js 配置
});

Step 5:App Router 布局

// src/app/[locale]/layout.tsx
import { NextIntlClientProvider } from 'next-intl';
import { getMessages } from 'next-intl/server';
import { notFound } from 'next/navigation';
import { routing } from '@/i18n/routing';
export function generateStaticParams() {
return routing.locales.map((locale) => ({ locale }));
}
export default async function LocaleLayout({
children,
params: { locale },
}: {
children: React.ReactNode;
params: { locale: string };
}) {
if (!routing.locales.includes(locale as any)) {
notFound();
}
// 获取服务端消息(避免重复加载)
const messages = await getMessages();
return (
<html lang={locale} dir={locale.startsWith('ar') ? 'rtl' : 'ltr'}>
<body>
<NextIntlClientProvider messages={messages}>
{children}
</NextIntlClientProvider>
</body>
</html>
);
}

Step 6:翻译文件

// messages/zh-CN.json
{
"common": {
"loading": "加载中...",
"error": "出错了,请重试",
"confirm": "确认",
"cancel": "取消"
},
"nav": {
"home": "首页",
"products": "商品",
"cart": "购物车 ({count, plural, =0 {空} one {# 件} other {# 件}})"
},
"product": {
"addToCart": "加入购物车",
"outOfStock": "暂时缺货",
"price": "{price, number, ::currency/CNY}"
}
}
// messages/en-US.json
{
"common": {
"loading": "Loading...",
"error": "Something went wrong, please try again",
"confirm": "Confirm",
"cancel": "Cancel"
},
"nav": {
"home": "Home",
"products": "Products",
"cart": "Cart ({count, plural, =0 {empty} one {# item} other {# items}})"
},
"product": {
"addToCart": "Add to Cart",
"outOfStock": "Out of Stock",
"price": "{price, number, ::currency/USD}"
}
}

Step 7:在组件中使用

// 服务端组件
import { useTranslations } from 'next-intl';
export default function ProductCard({ product }) {
const t = useTranslations('product');
return (
<div>
<h2>{product.name}</h2>
<p>{t('price', { price: product.price })}</p>
<button disabled={!product.inStock}>
{product.inStock ? t('addToCart') : t('outOfStock')}
</button>
</div>
);
}
// 客户端组件
'use client';
import { useTranslations } from 'next-intl';
export function CartButton({ count }: { count: number }) {
const t = useTranslations('nav');
return <button>{t('cart', { count })}</button>;
}

Step 8:语言切换组件

// src/components/LocaleSwitcher.tsx
'use client';
import { useRouter, usePathname } from 'next/navigation';
import { useLocale } from 'next-intl';
const localeNames: Record<string, string> = {
'zh-CN': '中文',
'en-US': 'English',
'ja-JP': '日本語',
};
export function LocaleSwitcher() {
const router = useRouter();
const pathname = usePathname();
const currentLocale = useLocale();
function handleChange(newLocale: string) {
// 替换 URL 中的 locale 部分
const segments = pathname.split('/');
segments[1] = newLocale;
router.push(segments.join('/'));
}
return (
<select value={currentLocale} onChange={(e) => handleChange(e.target.value)}>
{Object.entries(localeNames).map(([code, name]) => (
<option key={code} value={code}>{name}</option>
))}
</select>
);
}

方案 B:next-i18next(Pages Router)

安装

npm install next-i18next react-i18next i18next

配置

// next-i18next.config.js
module.exports = {
i18n: {
defaultLocale: 'zh-CN',
locales: ['zh-CN', 'en-US', 'ja-JP'],
},
// 翻译文件路径
localePath: './public/locales',
// 重载翻译文件时间间隔(开发模式)
reloadOnPrerender: process.env.NODE_ENV === 'development',
};
// next.config.js
const { i18n } = require('./next-i18next.config');
module.exports = { i18n };

翻译文件结构

public/locales/
├── zh-CN/
│   ├── common.json    # 通用文本
│   ├── product.json   # 商品相关
│   └── checkout.json  # 结算相关
├── en-US/
│   ├── common.json
│   ├── product.json
│   └── checkout.json
└── ja-JP/
├── common.json
├── product.json
└── checkout.json
// public/locales/zh-CN/common.json
{
"loading": "加载中...",
"error": "出错了,请重试",
"nav": {
"home": "首页",
"cart": "购物车({{count}} 件)"
}
}

在页面中使用

// pages/products/[id].tsx
import { GetStaticProps } from 'next';
import { serverSideTranslations } from 'next-i18next/serverSideTranslations';
import { useTranslation } from 'next-i18next';
export const getStaticProps: GetStaticProps = async ({ locale }) => {
return {
props: {
...(await serverSideTranslations(locale ?? 'zh-CN', ['common', 'product'])),
},
};
};
export default function ProductPage({ product }) {
// 使用 namespace
const { t: tCommon } = useTranslation('common');
const { t: tProduct } = useTranslation('product');
return (
<div>
<nav>{tCommon('nav.home')}</nav>
<h1>{tProduct('title')}</h1>
<p>{tCommon('nav.cart', { count: 3 })}</p>
</div>
);
}

方案 C:纯 React(react-intl)

npm install react-intl
// App.tsx
import { IntlProvider, FormattedMessage, FormattedNumber } from 'react-intl';
const messages = {
'zh-CN': {
'product.price': '价格:{price, number, ::currency/CNY}',
'cart.items': '{count, plural, =0 {购物车是空的} one {# 件商品} other {# 件商品}}',
},
'en-US': {
'product.price': 'Price: {price, number, ::currency/USD}',
'cart.items': '{count, plural, =0 {Cart is empty} one {# item} other {# items}}',
},
};
function App() {
const [locale, setLocale] = useState('zh-CN');
return (
<IntlProvider locale={locale} messages={messages[locale]}>
<ProductList />
</IntlProvider>
);
}
// 组件内使用
function CartButton({ count, price }) {
return (
<div>
<FormattedMessage id="cart.items" values={{ count }} />
<FormattedMessage id="product.price" values={{ price }} />
</div>
);
}

TypeScript 类型安全(next-intl)

next-intl 支持翻译 key 的 TypeScript 类型推断,防止拼错 key:

// src/types/global.d.ts
import messages from '../../messages/zh-CN.json';
// 声明翻译消息类型
declare global {
interface IntlMessages extends Messages {}
}
type Messages = typeof messages;
// 使用时有类型提示和错误检查
const t = useTranslations('product');
t('addToCart');    // ✅
t('addToCartXXX'); // ❌ TypeScript 报错:不存在该 key

常见问题

Q:SSR 时翻译会闪烁吗?

A:使用 next-intl 时,服务端渲染直接输出已翻译的 HTML,不会闪烁。客户端水合(hydration)时使用相同的翻译数据,保持一致。

Q:如何处理动态翻译内容(来自 CMS)?

// 静态翻译文件 + 动态 CMS 内容分离
const t = useTranslations('product'); // 静态 UI 文本
// 动态内容直接从 API 获取对应语言版本
const { locale } = useLocale();
const { data: product } = useSWR(`/api/products/${id}?locale=${locale}`);

Q:t() 函数里可以用 HTML 吗?

// next-intl 的富文本消息
// messages/zh-CN.json
{
"terms": "点击即表示您同意我们的<link>服务条款</link>"
}
// 组件中
t.rich('terms', {
link: (chunks) => <a href="/terms">{chunks}</a>,
});

下一节:Next.js 的两种方案讲完了,下面看 Vue / Nuxt 生态如何处理多语言。

vue-i18n 配置与实战