Next.js: 国际化与中间件

最后更新:2026-08-26

国际化不是简单的翻译——是 routing、locale detection、RTL 布局的系统工程。

1. 你将学到


2. 一个国际化产品经理的真实故事

(1) 痛点:中东用户发来 47 条差评

Alice 的 TaskFlow 上线后收到大量来自沙特和阿联酋用户的投诉:

"Dashboard 依然是英文,日期格式 07/06/2026 对他们毫无意义(沙特用 06/07/2026),数字 1,234.56 在阿拉伯国家显示异常。最严重的是——页面从右向左滚动,所有布局都是反的。"

问题 用户反馈 影响用户数
页面未翻译阿拉伯语 "看不懂菜单" 47 条差评
日期格式错误 "日期是反的" 32 条投诉
布局未适配 RTL "按钮跑到右边去了" 28 条投诉
货币符号错误 "价格显示 USD" 15 条投诉

(2) next-intl + Middleware 的解法

next-intl 提供国际化框架,Middleware 根据浏览器语言自动切换。

TS
// middleware.ts — 国际化路由核心
import createMiddleware from 'next-intl/middleware'

export default createMiddleware({
  locales: ['zh', 'en', 'ja', 'ar'],
  defaultLocale: 'zh',
  localePrefix: 'always'       // URL 始终包含地区前缀
})

export const config = {
  matcher: ['/((?!_next|api|favicon.ico).*)']
}

(3) 收益

维度 改版前 改版后
支持语种 仅中文 中/英/日/阿 4 语种
语言检测 手动选择 自动检测浏览器语言
RTL 适配 ❌ 未适配 ✅ 完整镜像布局
日期/数字 硬编码中文格式 Intl API 自动格式化
沙特用户 NPS 32 78

3. next-intl 安装与项目结构

(1) 安装与目录结构

BASH
npm install next-intl
100%
graph TB
    A[next-intl] --> B[i18n/request.ts<br/>服务端配置]
    A --> C[messages/<br/>翻译字典]
    C --> D[en.json<br/>英文]
    C --> E[zh.json<br/>中文]
    C --> F[ja.json<br/>日文]
    C --> G[ar.json<br/>阿拉伯文]
    A --> H[middleware.ts<br/>语言检测 + 重写]
    A --> I[app/[locale]/<br/>动态路由段]

    style A fill:#cce5ff
    style H fill:#d4edda
    style I fill:#fff3cd
文件 作用
i18n/request.ts 服务端 i18n 配置入口
messages/en.json 英文字典
messages/ar.json 阿语字典(含 RTL 标记)
middleware.ts 自动检测语言 + URL 重写
app/[locale]/ 动态地区路由段

(2) 初始化配置

TS
// i18n/request.ts — next-intl 服务端配置
import { getRequestConfig } from 'next-intl/server'
import { hasLocale } from 'next-intl'
import { routing } from './routing'

export default getRequestConfig(async ({ requestLocale }) => {
  const requested = await requestLocale
  const locale = hasLocale(routing.locales, requested) ? requested : routing.defaultLocale

  return {
    locale,
    messages: (await import(`../messages/${locale}.json`)).default
  }
})
TS
// i18n/routing.ts — 路由配置共享
import { defineRouting } from 'next-intl/routing'

export const routing = defineRouting({
  locales: ['zh', 'en', 'ja', 'ar'],
  defaultLocale: 'zh',
  localeDetection: true,
  localePrefix: 'as-needed'    // 默认语种省略前缀
})

▶ 示例:完整的 Middleware 配置

TS
// middleware.ts — 语言检测 + Cookie 持久化
import createMiddleware from 'next-intl/middleware'
import { NextRequest, NextResponse } from 'next/server'

const intlMiddleware = createMiddleware({
  locales: ['zh', 'en', 'ja', 'ar'],
  defaultLocale: 'zh',
  localeDetection: true,
  localePrefix: 'always'
})

export default function middleware(req: NextRequest) {
  const { pathname } = req.nextUrl

  // API / _next 不处理
  if (pathname.startsWith('/api') || pathname.startsWith('/_next') || pathname === '/favicon.ico') {
    return NextResponse.next()
  }

  return intlMiddleware(req)
}

export const config = {
  matcher: ['/((?!api|_next/static|_next/image|favicon.ico).*)']
}

4. Middleware 路由策略

(1) 三种路由模式

100%
graph TB
    A[用户访问] --> B{Middleware 检测}
    B --> C[检查 Cookie<br/>next-intl-locale]
    B --> D[检查 Accept-Language<br/>HTTP 头]
    B --> E[默认语种]

    C --> F[已有选择 → 使用选择]
    D --> G[首次访问 → 检测浏览器语言]
    E --> H[无匹配 → 默认语种]
    F --> I[URL 重写 /{locale}/...]
    G --> I
    H --> I

    I --> J[写入 Cookie<br/>持久化选择]

    style B fill:#fff3cd
    style I fill:#d4edda
    style J fill:#cce5ff
路由模式 URL 格式 localePrefix 适用场景
始终前缀 /en/about / /ar/about 'always' 所有语种平等展示
按需前缀 /about(默认) / /en/about 'as-needed' 默认语种 SEO 友好
永不前缀 /about 'never' Cookie 驱动(不推荐)
TS
// middleware.ts — 手动控制检测逻辑
import { NextRequest, NextResponse } from 'next/server'
import { routing } from './i18n/routing'

const COOKIE_NAME = 'NEXT_LOCALE'

export default function middleware(req: NextRequest) {
  const { pathname } = req.nextUrl
  const cookieLocale = req.cookies.get(COOKIE_NAME)?.value
  const acceptLanguage = req.headers.get('Accept-Language')?.split(',')[0]?.split('-')[0]
  const browserLocale = acceptLanguage && routing.locales.includes(acceptLanguage as any) ? acceptLanguage : null

  // 已匹配语言
  const matchedLocale = cookieLocale || browserLocale || routing.defaultLocale

  // 路径已有语言前缀
  const pathLocale = routing.locales.find((l) => pathname.startsWith(`/${l}/`) || pathname === `/${l}`)
  if (pathLocale) return NextResponse.next()

  // 重写 URL 并设置 Cookie
  const url = new URL(`/${matchedLocale}${pathname}`, req.url)
  const res = NextResponse.rewrite(url)
  res.cookies.set(COOKIE_NAME, matchedLocale, { maxAge: 60 * 60 * 24 * 365 })
  return res
}

export const config = { matcher: ['/((?!api|_next|favicon.ico).*)'] }
💡 提示: Cookie 持久化确保用户在不同页面间切换时保留语言选择。用户手动切换语言时,更新 Cookie 值。

▶ 示例:语言切换组件

TSX
// components/LocaleSwitcher.tsx
'use client'
import { useLocale } from 'next-intl'
import { usePathname, useRouter } from '@/i18n/routing'
import { useTransition } from 'react'

const locales = [
  { code: 'zh', label: '中文' },
  { code: 'en', label: 'English' },
  { code: 'ja', label: '日本語' },
  { code: 'ar', label: 'العربية' }
]

export default function LocaleSwitcher() {
  const locale = useLocale()
  const router = useRouter()
  const pathname = usePathname()
  const [isPending, startTransition] = useTransition()

  const switchLocale = (nextLocale: string) => {
    startTransition(() => {
      router.replace(pathname, { locale: nextLocale })
    })
  }

  return (
    <select
      value={locale}
      onChange={(e) => switchLocale(e.target.value)}
      disabled={isPending}
      style={{ padding: '0.25rem 0.5rem' }}
    >
      {locales.map((l) => (
        <option key={l.code} value={l.code}>{l.label}</option>
      ))}
    </select>
  )
}

5. 多语种字典管理

(1) 字典文件结构

JSON
// messages/zh.json
{
  "nav": {
    "home": "首页",
    "dashboard": "仪表盘",
    "projects": "项目",
    "settings": "设置",
    "signIn": "登录",
    "signOut": "退出"
  },
  "home": {
    "title": "TaskFlow - 团队协作平台",
    "subtitle": "帮助 10,000+ 团队高效协同工作",
    "cta": "免费开始"
  },
  "dashboard": {
    "welcome": "欢迎回来,{name}",
    "stats": {
      "members": "{count} 名成员",
      "projects": "{count} 个项目",
      "tasks": "{count} 个任务"
    },
    "activity": "最近活动"
  },
  "common": {
    "loading": "加载中...",
    "error": "出错了",
    "save": "保存",
    "cancel": "取消",
    "delete": "删除",
    "confirm": "确认"
  }
}
JSON
// messages/ar.json
{
  "nav": {
    "home": "الرئيسية",
    "dashboard": "لوحة التحكم",
    "projects": "المشاريع",
    "settings": "الإعدادات",
    "signIn": "تسجيل الدخول",
    "signOut": "تسجيل الخروج"
  },
  "home": {
    "title": "TaskFlow - منصة التعاون الجماعي",
    "subtitle": "نساعد أكثر من 10,000 فريق على العمل بكفاءة",
    "cta": "ابدأ مجاناً"
  },
  "dashboard": {
    "welcome": "مرحباً بعودتك، {name}",
    "stats": {
      "members": "{count} أعضاء",
      "projects": "{count} مشروعاً",
      "tasks": "{count} مهمة"
    },
    "activity": "النشاطات الأخيرة"
  },
  "common": {
    "loading": "جارٍ التحميل...",
    "error": "حدث خطأ",
    "save": "حفظ",
    "cancel": "إلغاء",
    "delete": "حذف",
    "confirm": "تأكيد"
  }
}

(2) 在组件中使用翻译

TSX
// Server Component:使用 t() 函数
import { getTranslations } from 'next-intl/server'

export default async function HomePage() {
  const t = await getTranslations('home')

  return (
    <div>
      <h1>{t('title')}</h1>
      <p>{t('subtitle')}</p>
      <button>{t('cta')}</button>
    </div>
  )
}
TSX
// Client Component:useTranslations Hook
'use client'
import { useTranslations } from 'next-intl'

export default function DashboardHeader({ name }: { name: string }) {
  const t = useTranslations('dashboard')

  return (
    <h1>{t('welcome', { name })}</h1>
  )
}
💻 输出(中文): 欢迎回来,Alice 💻 输出(阿拉伯文): مرحباً بعودتك، Alice

▶ 示例:带复数的翻译

TSX
// 字典
{
  "tasks": {
    "remaining": "{count, plural, =0 {没有任务} one {# 个任务} other {# 个任务}}"
  }
}
TSX
import { useTranslations } from 'next-intl'

export default function TaskCount({ count }: { count: number }) {
  const t = useTranslations('tasks')

  return <span>{t('remaining', { count })}</span>
}
💻 输出(count=0): 没有任务 💻 输出(count=1): 1 个任务 💻 输出(count=5): 5 个任务

💡 提示: next-intl 使用 ICU MessageFormat 语法,支持复数、选择、数字格式化等高级功能。


6. generateStaticParams 预生成多语言页面

(1) 静态预生成原理

100%
graph LR
    A[generateStaticParams] --> B[返回 locale 列表]
    B --> C[zh: /zh/blog/post-1]
    B --> D[en: /en/blog/post-1]
    B --> E[ja: /ja/blog/post-1]
    B --> F[ar: /ar/blog/post-1]
    C --> G[构建时生成 4 个 HTML]
    D --> G
    E --> G
    F --> G

    style A fill:#cce5ff
    style G fill:#d4edda
语种 HTML 文件 访问路径
中文 out/zh/blog/post-1.html /zh/blog/post-1
英文 out/en/blog/post-1.html /en/blog/post-1
日文 out/ja/blog/post-1.html /ja/blog/post-1
阿拉伯文 out/ar/blog/post-1.html /ar/blog/post-1

(2) 根布局 locale 参数

TSX
// app/[locale]/layout.tsx — 根布局接收 locale 参数
import { NextIntlClientProvider } from 'next-intl'
import { getMessages, getTranslations } from 'next-intl/server'
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) {
  const { locale } = await params

  // 校验 locale 是否有效
  if (!routing.locales.includes(locale as any)) notFound()

  const messages = await getMessages()

  return (
    <html lang={locale} dir={locale === 'ar' ? 'rtl' : 'ltr'}>
      <body>
        <NextIntlClientProvider messages={messages}>
          {children}
        </NextIntlClientProvider>
      </body>
    </html>
  )
}

▶ 示例:多语言博客文章静态生成

TSX
// app/[locale]/blog/[slug]/page.tsx — 多语言 SSG
import { getTranslations } from 'next-intl/server'
import { routing } from '@/i18n/routing'

type Props = { params: Promise<{ locale: string; slug: string }> }

// 预生成所有语言的文章页
export async function generateStaticParams() {
  const posts = await fetch('https://api.taskflow.io/blog/posts').then(r => r.json())

  return routing.locales.flatMap((locale) =>
    posts.map((post: { slug: string }) => ({ locale, slug: post.slug }))
  )
}

export async function generateMetadata({ params }: Props) {
  const { locale, slug } = await params
  const post = await fetch(`https://api.taskflow.io/blog/${slug}`).then(r => r.json())

  return {
    title: locale === 'en' ? post.title : post.titleLocalized[locale],
    alternates: {
      languages: Object.fromEntries(
        routing.locales.map((l) => [l, `https://taskflow.io/${l}/blog/${slug}`])
      )
    }
  }
}

export default async function BlogPostPage({ params }: Props) {
  const { locale, slug } = await params
  const t = await getTranslations('blog')

  return (
    <article>
      <h1>{t('title')}</h1>
      <p>Locale: {locale} | Slug: {slug}</p>
    </article>
  )
}

7. RTL 布局支持

(1) RTL 与 LTR 差异

100%
graph TB
    subgraph LTR 布局
        A1[Logo<br/>靠左] --> A2[导航栏<br/>从左到右]
        A2 --> A3[侧边栏<br/>靠左]
        A3 --> A4[内容<br/>靠右]
    end
    subgraph RTL 布局
        B1[Logo<br/>靠右] --> B2[导航栏<br/>从右到左]
        B2 --> B3[侧边栏<br/>靠右]
        B3 --> B4[内容<br/>靠左]
    end

    style B1 fill:#d4edda
    style B2 fill:#d4edda
    style B3 fill:#d4edda
维度 LTR(中/英/日) RTL(阿拉伯语)
文字方向 从左到右 从右到左
导航栏 Logo 左 → 菜单右 Logo 右 → 菜单左
侧边栏 左侧 右侧
箭头图标 前进 前进
卡片边距 margin-left: auto margin-right: auto
表单提交按钮 右下 左下

(2) Tailwind CSS RTL 支持

TSX
// app/[locale]/layout.tsx — RTL 检测与 CSS 类名
export default async function LocaleLayout({ children, params }: {
  children: React.ReactNode
  params: Promise<{ locale: string }>
}) {
  const { locale } = await params
  const isRtl = locale === 'ar'

  return (
    <html lang={locale} dir={isRtl ? 'rtl' : 'ltr'}>
      <body className={isRtl ? 'rtl' : 'ltr'}>
        {children}
      </body>
    </html>
  )
}
CSS
/* app/globals.css — RTL 基础样式 */
.ltr { direction: ltr; }
.rtl { direction: rtl; }

/* 导航栏镜像 */
.rtl .nav-items { flex-direction: row-reverse; }
.rtl .sidebar { right: 0; left: auto; }
.rtl .main-content { margin-right: 260px; margin-left: 0; }

/* 图标镜像 */
.rtl .icon-arrow::before { content: '←'; }
.ltr .icon-arrow::before { content: '→'; }

/* 间距反转 */
.rtl .ml-auto { margin-right: auto; margin-left: 0; }
.rtl .mr-2 { margin-left: 0.5rem; margin-right: 0; }

▶ 示例:RTL 适配的 Dashboard 布局

TSX
// components/DashboardLayout.tsx — RTL 兼容布局
export default function DashboardLayout({
  sidebar,
  children,
  locale
}: {
  sidebar: React.ReactNode
  children: React.ReactNode
  locale: string
}) {
  const isRtl = locale === 'ar'

  return (
    <div style={{
      display: 'flex',
      flexDirection: isRtl ? 'row-reverse' : 'row',
      minHeight: '100vh'
    }}>
      {/* 侧边栏:LTR 在左,RTL 在右 */}
      <aside style={{
        width: 260,
        borderRight: isRtl ? 'none' : '1px solid #e5e7eb',
        borderLeft: isRtl ? '1px solid #e5e7eb' : 'none'
      }}>
        {sidebar}
      </aside>

      {/* 主内容区 */}
      <main style={{
        flex: 1,
        padding: '2rem',
        textAlign: isRtl ? 'right' : 'left'
      }}>
        {children}
      </main>
    </div>
  )
}
⚠️ 注意: RTL 不只是 direction: rtl,还需要镜像所有 flex 方向、margin/padding、图标箭头、文本对齐。


8. 完整示例:四语种官网 + RTL Dashboard

TSX
// app/[locale]/layout.tsx — 国际化根布局
import { NextIntlClientProvider } from 'next-intl'
import { getMessages } from 'next-intl/server'
import { routing } from '@/i18n/routing'
import { notFound } from 'next/navigation'
import LocaleSwitcher from '@/components/LocaleSwitcher'
import type { Metadata } from 'next'

type Props = { children: React.ReactNode; params: Promise<{ locale: string }> }

export async function generateMetadata({ params }: Props): Promise<Metadata> {
  const { locale } = await params
  return {
    title: 'TaskFlow',
    alternates: {
      canonical: `https://taskflow.io/${locale}`,
      languages: Object.fromEntries(
        routing.locales.map((l) => [l, `https://taskflow.io/${l}`])
      )
    }
  }
}

export default async function LocaleLayout({ children, params }: Props) {
  const { locale } = await params

  if (!routing.locales.includes(locale as any)) notFound()

  const messages = await getMessages()
  const isRtl = locale === 'ar'

  return (
    <html lang={locale} dir={isRtl ? 'rtl' : 'ltr'}>
      <head>
        {/* RTL 时加载 RTL CSS */}
        {isRtl && <link rel="stylesheet" href="/rtl.css" />}
      </head>
      <body style={{ margin: 0, fontFamily: 'system-ui, sans-serif' }}>
        <NextIntlClientProvider messages={messages}>
          <nav style={{
            display: 'flex',
            justifyContent: 'space-between',
            alignItems: 'center',
            padding: '1rem 2rem',
            borderBottom: '1px solid #e5e7eb',
            flexDirection: isRtl ? 'row-reverse' : 'row'
          }}>
            <strong>TaskFlow</strong>
            <LocaleSwitcher />
          </nav>
          <main style={{ padding: '2rem' }}>{children}</main>
        </NextIntlClientProvider>
      </body>
    </html>
  )
}
TSX
// app/[locale]/dashboard/page.tsx — RTL Dashboard
import { getTranslations } from 'next-intl/server'

type Props = { params: Promise<{ locale: string }> }

export default async function DashboardPage({ params }: Props) {
  const { locale } = await params
  const t = await getTranslations('dashboard')
  const isRtl = locale === 'ar'

  return (
    <div>
      <h1 style={{ textAlign: isRtl ? 'right' : 'left' }}>{t('welcome', { name: 'Alice' })}</h1>

      <div style={{
        display: 'flex',
        gap: '1rem',
        flexDirection: isRtl ? 'row-reverse' : 'row'
      }}>
        <div style={{ flex: 1, padding: '1.5rem', background: '#f0f9ff', borderRadius: 8 }}>
          <h3>{t('stats.members', { count: 12 })}</h3>
        </div>
        <div style={{ flex: 1, padding: '1.5rem', background: '#fef3c7', borderRadius: 8 }}>
          <h3>{t('stats.projects', { count: 45 })}</h3>
        </div>
        <div style={{ flex: 1, padding: '1.5rem', background: '#ecfdf5', borderRadius: 8 }}>
          <h3>{t('stats.tasks', { count: 230 })}</h3>
        </div>
      </div>

      <h2 style={{ textAlign: isRtl ? 'right' : 'left' }}>{t('activity')}</h2>
      <div style={{
        padding: '1rem',
        border: '1px solid #e5e7eb',
        borderRadius: 8,
        textAlign: isRtl ? 'right' : 'left'
      }}>
        <p>Alice 创建了项目 "Website Redesign"</p>
        <p>Bob 完成了 Task #47</p>
        <p>Charlie 加入了组织</p>
      </div>
    </div>
  )
}
TEXT 📖 仅展示
# 输出效果(中文):
TaskFlow
[选择语言: 中文 ▼]

欢迎回来,Alice
[12 名成员] [45 个项目] [230 个任务]

最近活动
Alice 创建了项目 "Website Redesign"
Bob 完成了 Task #47
Charlie 加入了组织
TEXT 📖 仅展示
# 输出效果(阿拉伯文):
TaskFlow
[اختيار اللغة: العربية ▼]

مرحباً بعودتك، Alice
[12 أعضاء] [45 مشروعاً] [230 مهمة]

النشاطات الأخيرة
أليس قامت بإنشاء مشروع "إعادة تصميم الموقع"
بوب أكمل المهمة رقم 47
تشارلي انضم إلى المنظمة

❓ 常见问题

Q next-intlnext-international 应该选哪个?
A next-intl 社区更大(5k⭐)、功能更全(复数/选择/数字格式化/RTL 检测)。next-international 更轻量。本教程使用 next-intl
Q 为什么 Middleware 中要用 URL 重写而不是重定向?
A rewrite 保持浏览器地址栏内容不变,内部映射到 /{locale}/path,对 SEO 和用户体验更好。redirect 会改变 URL,可能破坏用户分享的链接。
Q 阿拉伯语 RTL 布局需要额外做什么工作?
A 除了 direction: rtl,还要镜像 flex 方向(flex-direction: row-reverse)、调整 margin/padding 方向、翻转箭头图标、使用 text-align: start/end 替代 left/right。建议在 globals.css 中用 [dir="rtl"] 选择器统一处理。
Q generateStaticParams 会为每个语种生成单独 HTML 文件吗?
A 是的。例如 /zh/blog/post-1/en/blog/post-1/ar/blog/post-1 是三个独立的 HTML 文件。每个文件只包含对应语种的内容,加载速度最快。
Q 翻译字典中的变量参数怎么处理?
A 使用 ICU MessageFormat 语法:{name} 插入变量,{count, plural, =0 {无} one {# 个} other {# 个}} 处理复数。next-intlt()useTranslations() Hook 自动解析这些模板。

📖 小节


📝 作业

  1. 基础题(⭐):在现有项目中安装 next-intl,配置中英文两个字典,实现语言切换组件,在页面中使用 t() 渲染导航栏文本。

  2. 进阶题(⭐⭐):实现完整的多语言 SSG 博客:generateStaticParams 为 4 个语种预生成文章页面,每篇文章在构建时加载对应语种的字典,验证生成后的 HTML 文件结构。

  3. 挑战题(⭐⭐⭐):构建一个支持 RTL 的 Dashboard 页面:配置阿拉伯语和英语两个语种,使用 Tailwind CSS 的 rtl: 前缀和 flex-direction 镜像实现侧边栏、导航、统计卡片的完整布局适配。验证在 dir="rtl" 下所有元素都正确镜像显示。

Web-Tutorial.com

Web-Tutorial 技术团队

由多位开发者共同维护的编程教程平台。每篇教程由对应领域的开发者编写和审核,确保内容准确可靠。如发现任何问题,欢迎向我们反馈。

100%

🙏 帮我们做得更好

我们是刚上线的编程教程站,几个人的小团队,精力有限。页面虽经检查,难免还有疏漏——链接失效、排版错乱、内容有误、语言生硬……

如果您发现了,麻烦告诉我们,我们会在收到反馈后第一时间进行修复,再次感谢您的光临 🙏