Sistine Starter

国际化 (i18n)

next-intl 配置与多语言支持指南

国际化 (i18n)

Sistine Starter 使用 next-intl 提供完整的国际化支持,让你的应用轻松覆盖全球用户。本指南将介绍如何使用和扩展多语言功能。

next-intl 简介

next-intl 是专为 Next.js 设计的国际化库,具有以下特点:

  • 类型安全: 完整的 TypeScript 支持,自动推断翻译键
  • App Router 优化: 原生支持 Next.js 14 App Router
  • 零运行时开销: 编译时优化,性能出色
  • 灵活路由: 支持 [locale] 动态路由
  • 服务端优先: 服务端组件原生支持
  • Rich Features: 支持复数、日期格式化、数字格式化等

当前支持的语言

Sistine Starter 开箱即用支持以下语言:

语言代码语言名称翻译文件
enEnglish (英文)messages/en.json, messages/seo.en.json
zh中文 (简体中文)messages/zh.json, messages/seo.zh.json

默认语言: 英文 (en)

配置架构

1. 核心配置文件

i18n.config.ts

定义支持的语言和默认语言:

// i18n.config.ts
export const locales = ['en', 'zh'] as const;
export type Locale = (typeof locales)[number];

export const defaultLocale: Locale = 'en';
export const localePrefix = 'as-needed';

export const localeNames: Record<Locale, string> = {
  en: 'English',
  zh: '中文'
};

配置说明:

配置项说明示例
locales支持的语言列表['en', 'zh']
defaultLocale默认语言'en'
localePrefix路由前缀策略'as-needed' (默认语言无前缀)
localeNames语言显示名称{ en: 'English', zh: '中文' }

localePrefix 策略:

  • 'as-needed': 默认语言不显示前缀 (推荐)
    • 英文: /about (无前缀)
    • 中文: /zh/about (有前缀)
  • 'always': 所有语言都显示前缀
    • 英文: /en/about
    • 中文: /zh/about
  • 'never': 都不显示前缀 (不推荐)

lib/i18n.ts

配置消息加载逻辑:

// lib/i18n.ts
import { getRequestConfig } from 'next-intl/server';
import { notFound } from 'next/navigation';
import { locales, type Locale } from '@/i18n.config';

export default getRequestConfig(async ({ locale }) => {
  console.log('getRequestConfig called with locale:', locale);

  // 如果 locale 未定义,使用默认语言
  if (!locale) {
    console.log('Locale is undefined, using default locale: en');
    const messages = (await import(`../messages/en.json`)).default;
    const seoMessages = (await import(`../messages/seo.en.json`)).default;
    return {
      locale: 'en',
      messages: {
        ...messages,
        seo: seoMessages
      }
    };
  }

  // 验证 locale 是否有效
  if (!locales.includes(locale as Locale)) {
    console.log('Locale not found:', locale);
    notFound();
  }

  try {
    const messages = (await import(`../messages/${locale}.json`)).default;
    const seoMessages = (await import(`../messages/seo.${locale}.json`)).default;
    console.log('Messages loaded for locale:', locale);
    return {
      locale,
      messages: {
        ...messages,
        seo: seoMessages
      }
    };
  } catch (error) {
    console.error('Error loading messages for locale:', locale, error);
    notFound();
  }
});

关键逻辑:

  1. 动态加载翻译文件: 根据 locale 加载对应的 JSON 文件
  2. 合并消息: 将通用消息和 SEO 消息合并
  3. 错误处理: locale 不存在时返回 404

2. 中间件配置

middleware.ts

自动处理语言检测和重定向:

// middleware.ts
import createMiddleware from 'next-intl/middleware';
import { locales, defaultLocale, localePrefix } from './i18n.config';

export default createMiddleware({
  locales,
  defaultLocale,
  localePrefix
});

export const config = {
  matcher: [
    '/',
    '/((?!api|_next|_vercel|.*\\..*).*)'
  ]
};

工作原理:

  • 拦截所有页面请求 (除了 API、静态资源等)
  • 检测用户语言偏好 (通过 Accept-Language 请求头或 URL)
  • 自动重定向到正确的语言路径

示例:

用户访问: https://yourdomain.com/
浏览器语言: zh-CN
→ 重定向到: https://yourdomain.com/zh/

用户访问: https://yourdomain.com/
浏览器语言: en-US
→ 保持: https://yourdomain.com/ (默认语言无前缀)

3. 根布局配置

app/[locale]/layout.tsx

为每个语言路由提供 Provider:

// app/[locale]/layout.tsx
import { NextIntlClientProvider } from "next-intl";
import { getMessages, setRequestLocale } from "next-intl/server";
import { notFound } from "next/navigation";
import { locales } from "@/i18n.config";

export default async function LocaleLayout({
  children,
  params: { locale }
}: {
  children: React.ReactNode;
  params: { locale: string };
}) {
  // 验证 locale
  if (!locales.includes(locale as any)) {
    notFound();
  }

  // 设置当前请求的 locale
  setRequestLocale(locale);

  // 加载翻译消息
  const messages = await getMessages({ locale });

  return (
    <html lang={locale} suppressHydrationWarning>
      <body>
        <NextIntlClientProvider locale={locale} messages={messages}>
          {children}
        </NextIntlClientProvider>
      </body>
    </html>
  );
}

翻译文件结构

文件位置

messages/
├── en.json          # 英文翻译 (通用消息)
├── zh.json          # 中文翻译 (通用消息)
├── seo.en.json      # 英文 SEO 消息
└── seo.zh.json      # 中文 SEO 消息

消息结构

翻译文件使用 JSON 格式,支持嵌套结构:

{
  "common": {
    "brand": {
      "name": "SISTINE",
      "tagline": "Sistine AI & Sistine Labs"
    },
    "actions": {
      "signIn": "Sign in",
      "signUp": "Sign Up",
      "save": "Save",
      "cancel": "Cancel"
    }
  },
  "navigation": {
    "main": {
      "pricing": "Pricing",
      "blog": "Blog",
      "contact": "Contact"
    }
  },
  "auth": {
    "login": {
      "title": "Sign in to your account",
      "emailLabel": "Email address",
      "passwordLabel": "Password"
    }
  }
}

命名规范:

  • 使用 camelCase 命名键
  • 按功能模块分组 (auth, navigation, dashboard 等)
  • 通用消息放在 common 命名空间

路由格式

URL 结构

基于 localePrefix: 'as-needed' 配置:

页面英文 URL中文 URL
首页//zh
定价/pricing/zh/pricing
登录/login/zh/login
仪表板/dashboard/zh/dashboard
博客文章/blog/post-slug/zh/blog/post-slug

文件系统路由

app/
└── [locale]/                    # 动态路由段
    ├── (marketing)/
    │   ├── page.tsx            # 首页 (/ 或 /zh)
    │   ├── pricing/
    │   │   └── page.tsx        # 定价页 (/pricing 或 /zh/pricing)
    │   └── blog/
    │       └── page.tsx        # 博客列表
    ├── (auth)/
    │   ├── login/
    │   │   └── page.tsx        # 登录页
    │   └── signup/
    │       └── page.tsx        # 注册页
    └── (protected)/
        └── dashboard/
            └── page.tsx        # 仪表板

在组件中使用翻译

服务端组件 (Server Component)

使用 useTranslations (从 next-intl 导入):

import { useTranslations } from 'next-intl';

export default function ServerComponent() {
  const t = useTranslations('navigation.main');

  return (
    <nav>
      <a href="/pricing">{t('pricing')}</a>
      <a href="/blog">{t('blog')}</a>
      <a href="/contact">{t('contact')}</a>
    </nav>
  );
}

客户端组件 (Client Component)

同样使用 useTranslations,但需要标记 "use client":

"use client";

import { useTranslations } from 'next-intl';

export default function ClientComponent() {
  const t = useTranslations('auth.login');

  return (
    <form>
      <h1>{t('title')}</h1>
      <label>{t('emailLabel')}</label>
      <input type="email" placeholder={t('emailPlaceholder')} />
      <label>{t('passwordLabel')}</label>
      <input type="password" placeholder={t('passwordPlaceholder')} />
      <button>{t('signInButton')}</button>
    </form>
  );
}

使用多个命名空间

import { useTranslations } from 'next-intl';

export default function MultiNamespaceComponent() {
  const tCommon = useTranslations('common.actions');
  const tAuth = useTranslations('auth.login');

  return (
    <div>
      <h1>{tAuth('title')}</h1>
      <button>{tCommon('signIn')}</button>
      <button>{tCommon('cancel')}</button>
    </div>
  );
}

带变量的翻译

翻译文件:

{
  "dashboard": {
    "welcome": "Welcome back, {name}!",
    "creditsRemaining": "You have {count} credits remaining"
  }
}

组件中使用:

const t = useTranslations('dashboard');

<h1>{t('welcome', { name: user.name })}</h1>
<p>{t('creditsRemaining', { count: user.credits })}</p>

Rich Text (支持 HTML)

翻译文件:

{
  "terms": {
    "agreement": "By signing up, you agree to our <link>Terms of Service</link>"
  }
}

组件中使用:

import { useTranslations } from 'next-intl';

const t = useTranslations('terms');

<p>
  {t.rich('agreement', {
    link: (chunks) => <a href="/terms">{chunks}</a>
  })}
</p>

服务端和客户端的区别

服务端组件 (推荐)

优势:

  • ✅ 更好的性能 (消息在服务端渲染,不发送到客户端)
  • ✅ 更小的 bundle 体积
  • ✅ SEO 友好
  • ✅ 支持异步数据获取

使用场景:

  • 营销页面 (首页、定价、博客)
  • 静态内容
  • SEO 重要的页面

示例:

// app/[locale]/(marketing)/page.tsx
import { useTranslations } from 'next-intl';

export default function HomePage() {
  const t = useTranslations('hero');

  return (
    <section>
      <h1>{t('title')}</h1>
      <p>{t('description')}</p>
    </section>
  );
}

客户端组件

使用场景:

  • 交互式组件 (表单、对话框)
  • 需要 React hooks 的组件
  • 需要浏览器 API 的组件

示例:

"use client";

import { useTranslations } from 'next-intl';
import { useState } from 'react';

export default function LoginForm() {
  const t = useTranslations('auth.login');
  const [email, setEmail] = useState('');

  return (
    <form>
      <input
        type="email"
        value={email}
        onChange={(e) => setEmail(e.target.value)}
        placeholder={t('emailPlaceholder')}
      />
    </form>
  );
}

添加新语言

步骤 1: 复制翻译文件

复制英文翻译文件作为模板(以法语为例):

# 复制通用消息
cp messages/en.json messages/fr.json

# 复制 SEO 消息
cp messages/seo.en.json messages/seo.fr.json

步骤 2: 翻译消息

打开 messages/fr.jsonmessages/seo.fr.json,将所有英文文本翻译为目标语言。

示例:

{
  "common": {
    "brand": {
      "name": "SISTINE",
      "tagline": "Sistine AI & Sistine Labs"
    },
    "actions": {
      "signIn": "Se connecter",
      "signUp": "S'inscrire",
      "save": "Enregistrer",
      "cancel": "Annuler"
    }
  }
}

步骤 3: 更新 i18n.config.ts

添加新语言到配置:

// i18n.config.ts
export const locales = ['en', 'zh', 'fr'] as const; // 添加 'fr'
export type Locale = (typeof locales)[number];

export const defaultLocale: Locale = 'en';
export const localePrefix = 'as-needed';

export const localeNames: Record<Locale, string> = {
  en: 'English',
  zh: '中文',
  fr: 'Français' // 添加法语名称
};

步骤 4: 更新 middleware.ts

middleware.ts 会自动使用 i18n.config.ts 中的配置,无需额外修改。

步骤 5: 测试新语言

启动开发服务器并访问:

http://localhost:3000/fr

应该能看到新语言翻译的页面。

语言切换器组件

创建一个语言切换器让用户手动选择语言:

"use client";

import { useLocale } from 'next-intl';
import { usePathname, useRouter } from 'next/navigation';
import { locales, localeNames } from '@/i18n.config';

export function LanguageSwitcher() {
  const locale = useLocale();
  const pathname = usePathname();
  const router = useRouter();

  const handleChange = (newLocale: string) => {
    // 替换当前路径中的 locale
    const segments = pathname.split('/');
    if (locales.includes(segments[1] as any)) {
      segments[1] = newLocale;
    } else {
      segments.splice(1, 0, newLocale);
    }
    const newPath = segments.join('/');
    router.push(newPath);
  };

  return (
    <select
      value={locale}
      onChange={(e) => handleChange(e.target.value)}
      className="border rounded px-2 py-1"
    >
      {locales.map((loc) => (
        <option key={loc} value={loc}>
          {localeNames[loc]}
        </option>
      ))}
    </select>
  );
}

使用:

import { LanguageSwitcher } from '@/components/language-switcher';

<header>
  <nav>
    {/* 导航项 */}
  </nav>
  <LanguageSwitcher />
</header>

最佳实践

1. 翻译键的命名

✅ 推荐:

{
  "auth": {
    "login": {
      "title": "Sign in to your account",
      "emailLabel": "Email address",
      "submitButton": "Sign in"
    }
  }
}

❌ 不推荐:

{
  "authLoginTitle": "Sign in to your account",
  "authLoginEmailLabel": "Email address",
  "authLoginSubmitButton": "Sign in"
}

使用嵌套结构更易于管理和理解。

2. 保持翻译文件同步

确保所有语言文件具有相同的键结构:

# 英文文件
messages/en.json
{
  "hero": {
    "title": "...",
    "description": "..."
  }
}

# 中文文件 (必须有相同的键)
messages/zh.json
{
  "hero": {
    "title": "...",
    "description": "..."
  }
}

如果缺少键,会导致运行时错误。

3. 使用 TypeScript 类型检查

next-intl 提供类型安全的翻译:

import { useTranslations } from 'next-intl';

const t = useTranslations('auth.login');

// ✅ 类型安全 - IDE 会自动补全
t('title');
t('emailLabel');

// ❌ 编译时错误 - 键不存在
t('nonExistentKey'); // TypeScript 会报错

4. 避免在翻译中硬编码 URL

❌ 不推荐:

{
  "footer": {
    "privacyLink": "https://yourdomain.com/privacy"
  }
}

✅ 推荐:

{
  "footer": {
    "privacy": "Privacy Policy"
  }
}
<a href="/privacy">{t('footer.privacy')}</a>

5. 处理复数形式

翻译文件:

{
  "credits": {
    "remaining": "{count, plural, =0 {No credits remaining} =1 {1 credit remaining} other {# credits remaining}}"
  }
}

组件中使用:

const t = useTranslations('credits');

<p>{t('remaining', { count: 0 })}</p>  // "No credits remaining"
<p>{t('remaining', { count: 1 })}</p>  // "1 credit remaining"
<p>{t('remaining', { count: 5 })}</p>  // "5 credits remaining"

6. 日期和数字格式化

import { useFormatter } from 'next-intl';

export function FormattingExample() {
  const format = useFormatter();

  const date = new Date('2025-10-15');
  const number = 1234.56;

  return (
    <div>
      {/* 日期格式化 */}
      <p>{format.dateTime(date, { dateStyle: 'full' })}</p>
      {/* 英文: Wednesday, October 15, 2025 */}
      {/* 中文: 2025年10月15日星期三 */}

      {/* 数字格式化 */}
      <p>{format.number(number, { style: 'currency', currency: 'USD' })}</p>
      {/* 英文: $1,234.56 */}
      {/* 中文: US$1,234.56 */}
    </div>
  );
}

7. SEO 优化

为每个语言版本设置正确的 langhreflang:

// app/[locale]/layout.tsx
export default async function LocaleLayout({ params: { locale } }) {
  return (
    <html lang={locale}>
      <head>
        <link rel="alternate" hreflang="en" href="https://yourdomain.com/" />
        <link rel="alternate" hreflang="zh" href="https://yourdomain.com/zh" />
        <link rel="alternate" hreflang="x-default" href="https://yourdomain.com/" />
      </head>
      {/* ... */}
    </html>
  );
}

8. 翻译缺失的后备方案

配置默认命名空间和后备语言:

// lib/i18n.ts
export default getRequestConfig(async ({ locale }) => {
  // ...
  return {
    locale,
    messages: {
      ...messages,
      seo: seoMessages
    },
    // 翻译缺失时的后备语言
    defaultTranslationValues: {
      fallback: 'Translation missing'
    }
  };
});

常见问题

如何获取当前语言?

客户端组件:

"use client";
import { useLocale } from 'next-intl';

export function MyComponent() {
  const locale = useLocale(); // 'en' 或 'zh'
  return <p>Current locale: {locale}</p>;
}

服务端组件:

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

export default async function MyComponent() {
  const locale = await getLocale(); // 'en' 或 'zh'
  return <p>Current locale: {locale}</p>;
}

如何在 API 路由中使用翻译?

API 路由不直接支持翻译,但可以手动加载消息:

// app/api/hello/route.ts
import { NextRequest, NextResponse } from 'next/server';

export async function GET(request: NextRequest) {
  // 从请求头获取语言
  const locale = request.headers.get('accept-language')?.split(',')[0].split('-')[0] || 'en';

  // 动态加载消息
  const messages = (await import(`@/messages/${locale}.json`)).default;

  return NextResponse.json({
    message: messages.api.hello
  });
}

翻译文件太大怎么办?

将大型翻译文件拆分为多个小文件:

messages/
├── en/
│   ├── common.json
│   ├── auth.json
│   ├── dashboard.json
│   └── admin.json
└── zh/
    ├── common.json
    ├── auth.json
    ├── dashboard.json
    └── admin.json

然后在 lib/i18n.ts 中合并:

const commonMessages = (await import(`../messages/${locale}/common.json`)).default;
const authMessages = (await import(`../messages/${locale}/auth.json`)).default;
const dashboardMessages = (await import(`../messages/${locale}/dashboard.json`)).default;

return {
  locale,
  messages: {
    common: commonMessages,
    auth: authMessages,
    dashboard: dashboardMessages
  }
};

如何处理动态内容的翻译?

对于来自数据库的动态内容,需要在数据库中存储多语言版本:

例如 blog_posts 表可以为每种语言各保留一列 (如 title_entitle_zhcontent_encontent_zh)。建表语句请参考 数据库 文档中的多语言示例。

组件中使用:

import { useLocale } from 'next-intl';

export function BlogPost({ post }) {
  const locale = useLocale();
  const title = locale === 'zh' ? post.title_zh : post.title_en;
  const content = locale === 'zh' ? post.content_zh : post.content_en;

  return (
    <article>
      <h1>{title}</h1>
      <div>{content}</div>
    </article>
  );
}

如何测试翻译完整性?

编写测试确保所有语言文件具有相同的键:

// tests/i18n.test.ts
import enMessages from '@/messages/en.json';
import zhMessages from '@/messages/zh.json';

function getKeys(obj: any, prefix = ''): string[] {
  return Object.keys(obj).flatMap(key => {
    const value = obj[key];
    const fullKey = prefix ? `${prefix}.${key}` : key;
    return typeof value === 'object' && !Array.isArray(value)
      ? getKeys(value, fullKey)
      : [fullKey];
  });
}

test('All translations have the same keys', () => {
  const enKeys = getKeys(enMessages).sort();
  const zhKeys = getKeys(zhMessages).sort();

  expect(enKeys).toEqual(zhKeys);
});

如何在生产环境优化性能?

  1. 使用静态生成 (SSG):
// app/[locale]/page.tsx
export function generateStaticParams() {
  return locales.map(locale => ({ locale }));
}
  1. 启用 Tree-shaking: 只加载当前语言的消息
  2. 使用 CDN: 部署到 Vercel 等平台,自动全球加速

下一步