Next.js: 国际化与中间件
最后更新:2026-08-26
国际化不是简单的翻译——是 routing、locale detection、RTL 布局的系统工程。
1. 你将学到
next-intl国际化框架的安装与集成- Middleware 路由策略(
Accept-Language检测 + Cookie 持久化 + URL 重写) - 多语种字典管理(
en.json/ar.json结构化翻译) generateStaticParams预生成多语言静态页面- RTL 布局支持(阿拉伯语方向适配)
- 日期、数字、货币的国际化格式化
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
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) 三种路由模式
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 驱动(不推荐) |
(2) Accept-Language 检测 + 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) 静态预生成原理
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 差异
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-intl 和 next-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-intl 的 t() 和 useTranslations() Hook 自动解析这些模板。📖 小节
next-intl提供完整国际化方案:Middleware(语言检测)+ 字典管理 + RTL 支持- Middleware 按优先级检测:Cookie → Accept-Language → 默认语种,写入 Cookie 持久化
- 字典使用嵌套 JSON 结构管理,支持 ICU MessageFormat 复数/变量/选择
generateStaticParams为每个语种预生成独立 HTML,SSG 模式零运行时开销- RTL 适配需要
dir属性 + CSS 镜像(flex 方向 / margin / padding / 图标) - 日期、数字、货币使用 JavaScript
IntlAPI 自动格式化,无需额外库
📝 作业
-
基础题(⭐):在现有项目中安装
next-intl,配置中英文两个字典,实现语言切换组件,在页面中使用t()渲染导航栏文本。 -
进阶题(⭐⭐):实现完整的多语言 SSG 博客:
generateStaticParams为 4 个语种预生成文章页面,每篇文章在构建时加载对应语种的字典,验证生成后的 HTML 文件结构。 -
挑战题(⭐⭐⭐):构建一个支持 RTL 的 Dashboard 页面:配置阿拉伯语和英语两个语种,使用 Tailwind CSS 的
rtl:前缀和flex-direction镜像实现侧边栏、导航、统计卡片的完整布局适配。验证在dir="rtl"下所有元素都正确镜像显示。