Next.js 中的国际化:11-Locale 实现
为全球受众打造内容需要的不仅仅是通过谷歌翻译运行字符串。生产国际化实施涉及路由策略、RTL(从右到左)布局支持、服务器端区域设置解析、翻译键组织、SEO hreflang 信号和可持续翻译工作流程。如果出现其中任何一个错误,您要么会为阿拉伯语/乌尔都语用户提供损坏的布局,缺少会分割搜索排名的 hreflang 标签,或者会在内容增长的压力下崩溃的翻译管道。
本指南记录了 ECOSIRE.COM 上使用的完整 11 种语言环境设置 — 英语、中文、西班牙语、阿拉伯语、葡萄牙语、法语、德语、日语、土耳其语、印地语和乌尔都语 — 构建于 next-intl v4.8 之上。这里的每个模式都经过了 5,577 个 MDX 内容文件和 12,543 个翻译键的生产测试。
要点
- 使用
localePrefix: 'as-needed'— 英语没有前缀,其他语言环境使用/zh/、/ar/等。- next-intl v4 使用
routing.ts+navigation.ts模式 — 切勿直接在区域设置感知组件中从next/navigation导入- 阿拉伯语 (
ar) 和乌尔都语 (ur) 需要<html>上的dir="rtl"以及 RTL 兼容字体 (Noto Sans Canadian)en.json键必须是嵌套对象 - 平点分隔键会破坏命名空间解析generateMetadata()(绝不静态export const metadata),每个页面上的 hreflang 都带有alternates.languages- 服务器组件使用
getTranslations('namespace'),客户端组件使用useTranslations('namespace')- 管理页面使用两个翻译挂钩:
t用于模块特定密钥,tc用于共享admin.common密钥- 使用 Google Translate API 自动翻译新按键;永远不会阻碍手动翻译的开发
项目设置
pnpm add next-intl@4
每个应用程序的目录结构:
src/
i18n/
routing.ts # defineRouting() — locale list, prefix strategy
navigation.ts # createNavigation() — locale-aware Link, useRouter
request.ts # getRequestConfig() — server-side message loading
messages/
en.json # Source of truth (~12,543 keys)
zh.json
es.json
ar.json
pt.json
fr.json
de.json
ja.json
tr.json
hi.json
ur.json
路由配置
// src/i18n/routing.ts
import { defineRouting } from 'next-intl/routing';
export const locales = [
'en', 'zh', 'es', 'ar', 'pt', 'fr', 'de', 'ja', 'tr', 'hi', 'ur',
] as const;
export type Locale = (typeof locales)[number];
export const rtlLocales: Locale[] = ['ar', 'ur'];
export const localeNames: Record<Locale, string> = {
en: 'English',
zh: '中文',
es: 'Español',
ar: 'العربية',
pt: 'Português',
fr: 'Français',
de: 'Deutsch',
ja: '日本語',
tr: 'Türkçe',
hi: 'हिन्दी',
ur: 'اردو',
};
export const routing = defineRouting({
locales,
defaultLocale: 'en',
localePrefix: 'as-needed', // /en/blog → /blog, /zh/blog → /zh/blog
});
// src/i18n/navigation.ts
import { createNavigation } from 'next-intl/navigation';
import { routing } from './routing';
export const { Link, redirect, useRouter, usePathname, getPathname } =
createNavigation(routing);
中间件/代理
next-intl v4 使用中间件函数进行区域设置检测和路由:
// src/proxy.ts (not middleware.ts — naming matters for Next.js 16)
import createMiddleware from 'next-intl/middleware';
import { routing } from './i18n/routing';
import { NextRequest, NextResponse } from 'next/server';
const intlMiddleware = createMiddleware(routing);
export function middleware(request: NextRequest) {
// Auth protection for dashboard routes
const isDashboard = request.nextUrl.pathname.includes('/dashboard');
const isPortal = request.nextUrl.pathname.includes('/portal');
const authCookie = request.cookies.get('ecosire_auth');
if ((isDashboard || isPortal) && !authCookie) {
const loginUrl = new URL('/auth/login', request.url);
// Prevent open redirect: only allow relative redirect paths
const from = request.nextUrl.pathname;
if (from.startsWith('/') && !from.startsWith('//')) {
loginUrl.searchParams.set('from', from);
}
return NextResponse.redirect(loginUrl);
}
return intlMiddleware(request);
}
export const config = {
matcher: [
'/((?!api|_next/static|_next/image|.*\\..*|_vercel|favicon.ico).*)',
],
};
服务器端消息加载
// src/i18n/request.ts
import { getRequestConfig } from 'next-intl/server';
import { routing } from './routing';
export default getRequestConfig(async ({ requestLocale }) => {
let locale = await requestLocale;
// Validate locale is supported; fall back to default
if (!locale || !routing.locales.includes(locale as any)) {
locale = routing.defaultLocale;
}
return {
locale,
messages: (await import(`../messages/${locale}.json`)).default,
};
});
具有 RTL 和字体支持的区域设置布局
// src/app/[locale]/layout.tsx
import { NextIntlClientProvider } from 'next-intl';
import { getMessages, getTranslations } from 'next-intl/server';
import { notFound } from 'next/navigation';
import { routing, rtlLocales, type Locale } from '@/i18n/routing';
import { Noto_Sans, Noto_Sans_Arabic } from 'next/font/google';
const notoSans = Noto_Sans({ subsets: ['latin'], variable: '--font-sans' });
const notoArabic = Noto_Sans_Arabic({
subsets: ['arabic'],
variable: '--font-arabic',
});
export async function generateStaticParams() {
return routing.locales.map((locale) => ({ locale }));
}
export async function generateMetadata({ params }: { params: Promise<{ locale: string }> }) {
const { locale } = await params;
const t = await getTranslations({ locale, namespace: 'metadata' });
return {
title: { template: `%s | ${t('siteName')}`, default: t('defaultTitle') },
other: { 'content-language': locale }, // For Bing/AI engine language detection
};
}
export default async function LocaleLayout({
children,
params,
}: {
children: React.ReactNode;
params: Promise<{ locale: string }>;
}) {
const { locale } = await params;
if (!routing.locales.includes(locale as Locale)) {
notFound();
}
const messages = await getMessages();
const isRtl = rtlLocales.includes(locale as Locale);
return (
<html
lang={locale}
dir={isRtl ? 'rtl' : 'ltr'}
className={`${notoSans.variable} ${notoArabic.variable}`}
>
<body>
<NextIntlClientProvider messages={messages}>
{children}
</NextIntlClientProvider>
</body>
</html>
);
}
翻译关键组织
en.json 结构必须使用嵌套对象,而不是平点分隔的键:
// WRONG — flat keys break next-intl namespace resolution
{
"nav.home": "Home",
"nav.blog": "Blog",
"admin.common.save": "Save"
}
// CORRECT — nested objects
{
"nav": {
"home": "Home",
"blog": "Blog"
},
"admin": {
"common": {
"save": "Save",
"cancel": "Cancel",
"delete": "Delete",
"edit": "Edit",
"status": "Status"
},
"contacts": {
"title": "Contacts",
"addContact": "Add Contact",
"searchPlaceholder": "Search contacts..."
}
}
}
服务器组件
// Server component: use getTranslations (async)
import { getTranslations } from 'next-intl/server';
export default async function ServicesPage({ params }: Props) {
const { locale } = await params;
const t = await getTranslations({ locale, namespace: 'services.odoo' });
return (
<main>
<h1>{t('hero.title')}</h1>
<p>{t('hero.description')}</p>
</main>
);
}
// generateMetadata must also fetch translations for locale-aware metadata
export async function generateMetadata({ params }: Props): Promise<Metadata> {
const { locale } = await params;
const t = await getTranslations({ locale, namespace: 'services.odoo' });
return {
title: t('meta.title'),
description: t('meta.description'),
alternates: {
canonical: `https://ecosire.com${locale === 'en' ? '' : `/${locale}`}/services/odoo`,
languages: {
'x-default': 'https://ecosire.com/services/odoo',
en: 'https://ecosire.com/services/odoo',
zh: 'https://ecosire.com/zh/services/odoo',
ar: 'https://ecosire.com/ar/services/odoo',
// ... all 11 locales
},
},
};
}
客户端组件
// Client component: use useTranslations (synchronous)
'use client';
import { useTranslations } from 'next-intl';
// Admin pages use two hooks: module-specific + shared admin.common
export default function ContactsPage() {
const t = useTranslations('admin.contacts'); // Module-specific
const tc = useTranslations('admin.common'); // Shared terms
// IMPORTANT: stateConfig must be inside component (after hooks init)
const stateConfig = {
active: { label: tc('statusActive'), className: 'bg-green-100 text-green-800' },
inactive: { label: tc('statusInactive'), className: 'bg-gray-100 text-gray-700' },
};
return (
<div>
<h1>{t('title')}</h1>
<button>{tc('addNew')}</button>
{/* ... */}
</div>
);
}
语言切换器组件
// components/language-switcher.tsx
'use client';
import { useLocale } from 'next-intl';
import { usePathname, useRouter } from '@/i18n/navigation'; // NOT next/navigation
import { locales, localeNames } from '@/i18n/routing';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import { Globe } from 'lucide-react';
export function LanguageSwitcher() {
const locale = useLocale();
const pathname = usePathname();
const router = useRouter();
const handleLocaleChange = (newLocale: string) => {
router.replace(pathname, { locale: newLocale });
};
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button aria-label="Change language">
<Globe className="h-4 w-4" />
<span>{localeNames[locale as keyof typeof localeNames]}</span>
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
{locales.map((loc) => (
<DropdownMenuItem
key={loc}
onClick={() => handleLocaleChange(loc)}
className={loc === locale ? 'font-bold' : ''}
>
{localeNames[loc]}
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
);
}
翻译工作流程
添加新键
- 首先添加到
en.json(事实来源) - 运行翻译脚本:
node apps/web/scripts/translate-missing.mjs
该脚本查找 en.json 中存在但其他语言环境文件中缺失的键,并调用 Google Translate API:
// scripts/translate-missing.mjs (simplified)
import { Translate } from '@google-cloud/translate/build/src/v2/index.js';
import { readFileSync, writeFileSync } from 'fs';
const translate = new Translate({ key: process.env.GOOGLE_TRANSLATE_API_KEY });
async function translateMissingKeys(sourceLocale, targetLocale, sourceMessages, targetMessages) {
const missing = findMissingKeys(sourceMessages, targetMessages);
for (const [keyPath, value] of missing) {
const [translated] = await translate.translate(value, targetLocale);
setNestedKey(targetMessages, keyPath, translated);
}
return targetMessages;
}
验证翻译覆盖范围
# Check for missing keys across all locales
node apps/web/scripts/validate-blog-i18n.mjs
# Fix flat keys accidentally added
node apps/web/scripts/fix-en-flat-keys.mjs
博客内容翻译
对于 MDX 内容文件,frontmatter 标题仍为英文(标题为 i18n JSON),但正文内容按区域设置进行翻译:
src/content/blog/
en/ # English originals (or root directory)
odoo-erp-guide.mdx
zh/
odoo-erp-guide.mdx # Translated body
ar/
odoo-erp-guide.mdx
# ... 10 locale directories
// src/lib/blog.ts — locale-aware loader
export async function getPost(slug: string, locale: string) {
// Try locale-specific file first
const localePath = path.join(contentDir, locale, `${slug}.mdx`);
const englishPath = path.join(contentDir, `${slug}.mdx`);
const filePath = fs.existsSync(localePath) ? localePath : englishPath;
const raw = fs.readFileSync(filePath, 'utf8');
const { data: frontmatter, content } = matter(raw);
return { frontmatter, content, locale };
}
常见问题
为什么使用 localePrefix: 'as-needed' 而不是 always?
对于 as-needed,英语 URL 没有前缀 (/blog/post),而其他语言环境则有前缀 (/zh/blog/post)。这是标准约定 - 默认为英语,并且具有干净的 URL。使用 always,英语也会获得 /en/blog/post,这会创建不必要的重定向,并在您迁移时分割您现有的英语链接资产。
为什么 en.json 键必须是嵌套对象而不是平点分隔的字符串?
next-intl 的命名空间解析将命名空间字符串按点分割以遍历 JSON 树。如果您的键是平点分隔的字符串,例如 "admin.contacts.title",则 next-intl 会查找 messages.admin.contacts.title 作为嵌套路径,但会发现该键实际上是根级别的字符串 "admin.contacts.title",从而导致查找失败。始终使用实际的嵌套对象。
如何处理跨语言的多元化?
next-intl 使用 ICU 消息格式进行复数化。英语有两种复数形式(一个/其他),阿拉伯语有六种复数形式(零、一、二、少、多、其他)。在翻译值中使用 {count, plural, one {# item} other {# items}} ICU 语法,next-intl 自动为每个区域设置应用正确的复数规则。
我可以将 next-intl 与 Pages Router 一起使用吗?
next-intl v4 主要是为 App Router 设计的。对于页面路由器,使用 next-intl v2 或 v3。模式差异很大:Pages Router 在布局中使用 getStaticProps + NextIntlProvider,而 App Router 使用 getRequestConfig + NextIntlClientProvider。如果您使用的是 Pages Router,请考虑在大规模实施 i18n 之前迁移到 App Router。
如何设置对阿拉伯语和乌尔都语的 RTL 支持?
当区域设置为 ar 或 ur 时,在 <html> 元素上设置 dir="rtl"。使用 Tailwind 的 rtl: 变体前缀来实现特定于方向的样式(rtl:mr-4 在 RTL 中变为 ml-4)。加载兼容字体 - Noto Sans 阿拉伯语涵盖阿拉伯语和乌尔都语脚本。避免硬编码 left/right CSS 值;使用 start/end 逻辑属性(Tailwind v4 中的 ms-4、me-4)。
快速翻译大量内容的最佳方法是什么?
使用 Google Translate API 对所有按键和内容进行初始机器翻译,然后让母语人士审阅高可见度内容(主页、产品描述、CTA 副本)。对于博客内容,机器翻译可以作为起点,但应审查技术准确性。用于至少按流量排名前 3-5 个区域设置的母语人士审核的预算。
后续步骤
正确的国际化是一条有竞争力的护城河 - 您的内容可以吸引 11 倍以上的潜在客户,您的 SEO hreflang 信号会整合而不是分割链接资产,并且母语用户的转化率会显着提高。
ECOSIRE 构建并维护了完整的 11 语言环境实现,涵盖 5,577 个内容文件、12,543 个翻译键和 RTL 布局支持。如果您的 Next.js 应用程序需要国际化,请探索我们的前端工程服务,了解我们如何为您的代码库实现它。
作者
ECOSIRE Research and Development Team
在 ECOSIRE 构建企业级数字产品。分享关于 Odoo 集成、电商自动化和 AI 驱动商业解决方案的洞见。
相关文章
Next.js 16 App Router: Production Patterns and Pitfalls
Production-ready Next.js 16 App Router patterns: server components, caching strategies, metadata API, error boundaries, and performance pitfalls to avoid.
将电子商务扩展到国际市场:完整的战略指南
国际电子商务扩张的完整指南,涵盖市场研究、本地化、物流、支付、法律合规和营销策略。
用于国际扩张的 ERP:管理 2026 年的跨国运营
如何使用 ERP 系统管理国际扩张,包括多币种会计、税务合规、本地化、供应链管理和 Odoo 多公司配置。