跳到主要内容

next项目的多语言配置

本文的内容不是全面、通用、详尽的技术资料。 仅是个人实践记录,适用于本人已发布的网站。 供参考。 我发布此文时为2025年10月底,next.js的官方已推出16.0.0,我创建的新网站也是按照最新的版本来。

官方指南

关键词:Internationalization

Internationalization

**我的所有next项目均是基于App Router模式,typescript,pnpm包管理,之后不再说明。

路由

关键词: routing

本地化

关键词: localization

翻译

关键词:translation

学习资料

官方提供的极简路由和翻译案例(Minimal i18n routing and translations)](https://github.com/vercel/next.js/tree/canary/examples/i18n-routing)

案例中只是一个已经完成的项目,没有开发过程,所以我们分析一下步骤,然后结合我最近写的一个网站(next 15.X)照做。

案例中有2个我从未接触的组件: @formatjs/intl-localematchernegotiator,都是老项目了。 由于案例中不涉及翻译(t),所以没有用到next-intl等组件。

所以我看一下就决定按照之前项目的做法来。 此案例太老,只能参考。

接下来我们可以直接去看next-intl的官方教程。 为啥选这个呢?可能是因为名称简洁,而且在推荐列表里放在最上面吧,多语言的方案太多了。

步骤

安装next-intl

pnpm add next-intl

建立多语言文件库

在项目根目录上新建一个locale目录(官方案例叫messages)。

下面放语言文件json,如en.json, hans.json等,除了中文比较麻烦外,其他语言按照国际标准tag来写名字就行了。

json的格式大致为:

// en.json
{
"hey": "Hey, ",
"email": "Email",
"auth": {
"sign_in": "Sign in",
...},
...
}

// hans.json 简体中文
{
"hey": "你好!",
"email": "电子邮箱",
"auth": {
"sign_in": "登录",
...},
...
}

request配置文件

新建一个i18n目录,和app平级即可(我是放在/src下)。 在其中建立request.ts,内容:


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

export default getRequestConfig(async () => {
// Static for now, we'll change this later
const locale = 'en';

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

修改next.config.ts

这个文件在你的项目根目录,默认长这样:


import type { NextConfig } from "next";

const nextConfig: NextConfig = {
/* config options here */
};

export default nextConfig;

现在改成这样:


import {NextConfig} from 'next';
import createNextIntlPlugin from 'next-intl/plugin';

const nextConfig: NextConfig = {};

const withNextIntl = createNextIntlPlugin();
export default withNextIntl(nextConfig);

layout中包裹body

为了使以上request配置对客户端组件生效,可以在根布局文件中,用一个NextIntlClientProvider来包裹children们。


// app/layout.tsx
import {NextIntlClientProvider} from 'next-intl';

type Props = {
children: React.ReactNode;
};

export default async function RootLayout({children}: Props) {
return (
<html>
<body>
<NextIntlClientProvider>{children}</NextIntlClientProvider>
</body>
</html>
);
}

之前我不是这样做的,是靠middleware来引入一个多语言的routing配置。 感觉现在的方法更优雅一些。

在组件中使用

接下来就可以试用一下了,如翻译:


// 客户端组件
import {useTranslations} from 'next-intl';

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

// 服务端组件
import {getTranslations} from 'next-intl/server';

export default async function HomePage() {
const t = await getTranslations('HomePage');
return <h1>{t('title')}</h1>;
}

文件结构

刚才的例子中,文件结构大致是这样:


├── locale
│ ├── en.json
│ └── ...
├── next.config.ts
└── src
├── i18n
│ └── request.ts
└── app
├── layout.tsx
└── page.tsx

本地化路由

这是个重要概念。 如果你要将本地化语言放到路径名称中来实现路由导航,如/en/about or example.de/über-uns的形式,你需要在app目录下设置一个顶级的[locale]动态段。

如果不喜欢这种模式,则可以用cookie等方式来传递本地化选项,修改request.ts:


import {cookies} from 'next/headers';
import {getRequestConfig} from 'next-intl/server';

export default getRequestConfig(async () => {
const store = await cookies();
const locale = store.get('locale')?.value || 'en';

return {
locale
// ...
};
});

之前我也用过这方法,后来还是随大流用动态字段的本地化路由了。 下面就是说这个方法。

进一步完善

概述

next-intl在两个地方集成了Next.js的路由系统:

  1. Proxy / middleware: 协商本地化信息并处理重定向和重写(如:  / → /en
  2. Navigation APIs: 对Next.js的导航API们(如<Link />)进行轻量级的包裹

这样一来,你就可以统一用 <Link href="/about"> 这类 API 来描述应用,而诸如「当前语种」「面向用户的实际路径」等细节都会被自动处理——例如最终呈现的地址可能是 /de/über-uns

设置本地化路由

首先我们在app下新建动态目录[locale], 并把layout.tsx, page.tsx等页面文件移到其下。

接下来就完全参考官方教程设置。 i18n目录下加入proxy.ts和navigation.ts等文件,当然request.ts也进行修改(引入requestLocale等参数)。

layout.tsx也要加入一些新的判断,以防传入不存在的locale值:


// app/[locale]/layout.tsx

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

type Props = {
children: React.ReactNode;
params: Promise<{locale: string}>;
};

export default async function LocaleLayout({children, params}: Props) {
// Ensure that the incoming `locale` is valid
const {locale} = await params;
if (!hasLocale(routing.locales, locale)) {
notFound();
}

// ...
}

路由配置

进一步配置,见官方教程。 这里有个配置项叫localePrefix,比较重要。 以前我喜欢选择'never'或者'as-needed',但现在为了简化都选择'always'(默认)。

接下来就引出代理(proxy),中间件(middleware)和匹配器(matcher)的内容。

代理/中间件

官方教程

这里有个关于集成Supabase Authentication的案例,我正好不久前用上。

注意,最新的文档不用middleware.ts了,而是改用一个proxy.ts来配置中间件。 proxy.ts was called middleware.ts up until Next.js 16. next-intl是亲儿子吧,更新好快。

// src/proxy.ts

import createMiddleware from 'next-intl/middleware';
import {routing} from './i18n/routing';

export default createMiddleware(routing);

export const config = {
// Match all pathnames except for
// - … if they start with `/api`, `/trpc`, `/_next` or `/_vercel`
// - … the ones containing a dot (e.g. `favicon.ico`)
matcher: '/((?!api|trpc|_next|_vercel|.*\\..*).*)'
};

导航

一些导航相关API和组件的处理,如Link, usePathname等。看教程就行。

翻译文件的名称空间

我的文字资料太多,需要多个目录来组织不同的翻译文件,以便清晰分类。 官方没有说,我发现的实践方式是改request.ts为:


import { getRequestConfig } from 'next-intl/server';
import { routing } from './routing';
import { LangTag } from '@/app-types/common';

export default getRequestConfig(async ({ requestLocale }) => {

// This typically corresponds to the `[locale]` segment
let locale = await requestLocale;

// Ensure that a valid locale is used
if (!locale || !routing.locales.includes(locale as LangTag)) {
locale = routing.defaultLocale;
}

const common = (await import(`@locale/common/${locale}.json`)).default;
const yi = (await import(`@locale/yi/${locale}.json`)).default;

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

这里我的messages/locale目录结构:

locale
├── common
│ ├── en.json
│ ├── hans.json
│ ├── hant.json
│ ├── ja.json
│ └── ko.json
└── yi
├── en.json
├── hans.json
├── hant.json
├── ja.json
└── ko.json

其他问题

中文的Unicode语言标签

我觉得zh-Hans这样的写法太麻烦,于是用hans,hant分别代表简繁中文,但是next报错: An invalid locale was provided: "hans" Please ensure you're using a valid Unicode locale identifier (e.g. "en-US"). 虽然不影响使用,但看着烦。

根本原因next-intl(以及底层的 Intl.Locale) 只认标准的 Unicode locale 标识符
"hans" 不是合法标识符;正确写法是 "zh-Hans""zh-CN" 等。

我要修改的话,很多地方包括json的文件名都要改了。

为啥浏览器记住了上次的手动语言选项?

据AI说是这样:

  1. 中间件(next-intl/middleware)默认把用户显式选中的 locale 写进 cookie(键名 NEXT_LOCALE)。
  2. 下次请求带上了这个 cookie,中间件就不再按 Accept-Language 猜,而是直接用 cookie 值做重定向。
  3. 所以看上去“记住了”。

语言切换组件

我用了点Shadcn UI。


'use client'



import { Check, ChevronDown } from 'lucide-react'

import { Button } from '@/components/ui/button'

import {

DropdownMenu,

DropdownMenuContent,

DropdownMenuItem,

DropdownMenuTrigger,

} from '@/components/ui/dropdown-menu'

import { usePathname, useRouter } from 'next/navigation'

import { Locale } from '@share/types'

import { useTransition } from 'react'



const localeNames: Record<Locale, string> = {

"zh-Hans": '简体中文',

"zh-Hant": '繁體中文',

es: 'Español',

ja: '日本語',

ko: '한국어',

en: 'English',

}



const LOCALES = Object.keys(localeNames) as Locale[]


export default function LocaleSwitcher() {

const [isPending, startTransition] = useTransition();

const router = useRouter()

const pathname = usePathname() // e.g. '/en/about'

const currentLocale = pathname.split('/')[1] as Locale

const handleSelect = (next: Locale) => {


// 把 /ja/about -> /ko/about

const newPath = pathname.replace(`/${currentLocale}`, `/${next}`)
startTransition(() => {
router.replace(newPath)
});
}

return (

<DropdownMenu>

<DropdownMenuTrigger asChild>

<Button variant="ghost" size="sm" className="flex items-center gap-1">

{localeNames[currentLocale]}

<ChevronDown className="h-4 w-4" />

</Button>

</DropdownMenuTrigger>



<DropdownMenuContent align="end">

{LOCALES.map((code) => (

// Option A: programmatic navigation

<DropdownMenuItem
key={code}
onClick={() => handleSelect(code)}
className="flex items-center justify-between"
>

{localeNames[code]}

{code === currentLocale && <Check className="h-4 w-4 ml-2" />}

</DropdownMenuItem>



/* Option B (alternative):

You can also render <Link href={buildPathWithLocale(code)}> here instead of onClick.

That gives native anchor semantics and prefetching (if enabled), but in a dropdown

sometimes custom behavior is preferred.

*/

))}

</DropdownMenuContent>

</DropdownMenu>

)

}