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 TeamTechnical Writing
The ECOSIRE technical writing team covers Odoo ERP, Shopify eCommerce, AI agents, Power BI analytics, GoHighLevel automation, and enterprise software best practices. Our guides help businesses make informed technology decisions.
相关文章
Odoo 阿根廷本地化 2026:ARCA、IVA 和 IIBB 设置
配置 Odoo 以实现阿根廷合规性:l10n_ar_edi ARCA WSFE 发票,采用 CAE、IVA 21%、多省 IIBB、RG 5616 Recibos、SICORE。
Odoo 澳大利亚本地化 2026:GST、BAS、ATO STP 和 ABN 设置
配置 Odoo 以实现澳大利亚合规性:l10n_au 图表、GST 10%、BAS 标签 G1-G24、ATO 单点触控工资单第 2 阶段、ABN、超级 12%。
Odoo 巴西本地化 2026:NFe、ICMS 和 PIS/COFINS 设置
配置 Odoo 以实现巴西合规性:OCA l10n_br NFe/NFSe、ICMS 多州、IPI、PIS/COFINS、eSocial、Reinf、税收改革 CBS/IBS 路径。