Internationalization in Next.js: 11-Locale Implementation

Build a production-ready 11-locale Next.js app with next-intl v4. Covers routing, RTL support, translation workflows, hreflang SEO, and server/client component patterns.

E
ECOSIRE Research and Development Team
|2026年3月19日6 分钟阅读1.4k 字数|

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>
  );
}

翻译工作流程

添加新键

  1. 首先添加到 en.json (事实来源)
  2. 运行翻译脚本:
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 支持?

当区域设置为 arur 时,在 <html> 元素上设置 dir="rtl"。使用 Tailwind 的 rtl: 变体前缀来实现特定于方向的样式(rtl:mr-4 在 RTL 中变为 ml-4)。加载兼容字体 - Noto Sans 阿拉伯语涵盖阿拉伯语和乌尔都语脚本。避免硬编码 left/right CSS 值;使用 start/end 逻辑属性(Tailwind v4 中的 ms-4me-4)。

快速翻译大量内容的最佳方法是什么?

使用 Google Translate API 对所有按键和内容进行初始机器翻译,然后让母语人士审阅高可见度内容(主页、产品描述、CTA 副本)。对于博客内容,机器翻译可以作为起点,但应审查技术准确性。用于至少按流量排名前 3-5 个区域设置的母语人士审核的预算。


后续步骤

正确的国际化是一条有竞争力的护城河 - 您的内容可以吸引 11 倍以上的潜在客户,您的 SEO hreflang 信号会整合而不是分割链接资产,并且母语用户的转化率会显着提高。

ECOSIRE 构建并维护了完整的 11 语言环境实现,涵盖 5,577 个内容文件、12,543 个翻译键和 RTL 布局支持。如果您的 Next.js 应用程序需要国际化,请探索我们的前端工程服务,了解我们如何为您的代码库实现它。

E

作者

ECOSIRE Research and Development Team

在 ECOSIRE 构建企业级数字产品。分享关于 Odoo 集成、电商自动化和 AI 驱动商业解决方案的洞见。

通过 WhatsApp 聊天