next项目的多语言配置
本文的内容不是全面、通用、详尽的技术资料。 仅是个人实践记录,适用于本人已发布的网站。 供参考。 我发布此文时为2025年10月底,next.js的官方已推出16.0.0,我创建的新网站也是按照最新的版本来。
官方指南
关键词: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-localematcher, negotiator,都是老项目了。
由于案例中不涉及翻译(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的路由系统:
- Proxy / middleware: 协商本地化信息并处理重定向和重写(如:  /→/en)
- 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.tswas calledmiddleware.tsup 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说是这样:
- 中间件(next-intl/middleware)默认把用户显式选中的 locale 写进 cookie(键名NEXT_LOCALE)。
- 下次请求带上了这个 cookie,中间件就不再按 Accept-Language猜,而是直接用 cookie 值做重定向。
- 所以看上去“记住了”。
语言切换组件
我用了点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>
)
}