
Next.js 16 + i18next: 不改路由的 Cookie 国际化方案
实操指南:在 Next.js 16 App Router 项目中用 i18next 实现 Cookie 驱动的国际化——零路由改造、零 URL 前缀、完整 TypeScript 类型支持,涵盖首次访问 Cookie 回写、per-request 实例隔离、Server Action 写 Cookie 等生产级细节。
网上绝大多数 Next.js i18n 教程都在讲 URL 路由方案:/en/about、/zh/about,然后让你把整个应用塞进 [locale] 动态段,改造每个页面文件,还要处理所有内部链接的 locale 透传。
但你的个人博客只是想加个中英切换按钮而已。URL 不变,偏好存 Cookie,不折腾 SEO。
核心思路:Cookie 驱动的单一真相来源
整个架构围绕一个原则展开:i18n_lang 这个 Cookie 是语言状态的唯一载体。Proxy、Server Components、Client Components 全部读写同一个 Cookie,没有自定义 Header,没有状态副本。
首次访问(无 Cookie)
┌──────────────────────────────────────────────┐
│ proxy.ts │
│ detectLocale() │
│ → Cookie 为空 → 解析 Accept-Language │
│ → 匹配到 "zh" → persisted: false │
│ withLocale() │
│ → Set-Cookie: i18n_lang=zh ◄── 回写 │
└──────────────────┬───────────────────────────┘
│ Cookie 已写入响应
┌─────────────┼─────────────┐
▼ ▼
┌───────────────────┐ ┌──────────────────┐
│ server.ts │ │ client.ts │
│ getLang() │ │ LanguageDetector│
│ cookies().get() │ │ order: │
│ → "zh" │ │ ['cookie', │
│ getT('about') │ │ 'navigator'] │
│ → 中文内容。 │ │ → "zh" (cookie) │
└───────────────────┘ └──────────────────┘
用户切换语言
┌──────────────────────────────────────────────┐
│ ToggleLanguage.tsx │
│ switchLocaleAction('en') // Server Action │
│ → cookies().set('i18n_lang', 'en') │
│ i18n.changeLanguage('en') // 客户端立即生效 │
│ router.refresh() // RSC 重新渲染 │
│ → server.ts getLang() → "en" │
└──────────────────────────────────────────────┘几个根本设计决策:
- Cookie 是唯一的语言载体——没有
x-i18n-lang之类的自定义 Header - 服务端通过
cookies()直接读 Cookie,不经任何中转 - 客户端通过
LanguageDetector读 Cookie,navigator.language是最后兜底 - 语言切换用 Server Action 写 Cookie,不用
document.cookie - Proxy 在首次访问时回写 Cookie,保证第一次请求的服务端/客户端一致性
- URL 永不改变——
/blog对所有语言都是同一个地址
Cookie 方案 vs URL 方案的取舍
- URL 方案(
/en/about、/zh/about):搜索引擎友好(hreflang、独立索引),但需要[locale]重构路由、middleware 守卫、处理所有内部链接的 locale 透传 - Cookie 方案:零路由改动,同一 URL 服务所有语言,但搜索引擎只能看到一个语言版本,不能设 hreflang
个人博客、内部工具、管理后台——Cookie 方案完全够用。如果你的站点靠 SEO 吃饭,老老实实用 URL 方案。
依赖安装
pnpm add i18next react-i18next i18next-resources-to-backend \
i18next-browser-languagedetector accept-language| 包 | 作用 |
|---|---|
i18next | 核心翻译引擎 |
react-i18next | React 绑定——useTranslation Hook |
i18next-resources-to-backend | 通过动态 import() 按需加载 JSON 翻译文件 |
i18next-browser-languagedetector | 客户端从 Cookie / 浏览器自动检测语言 |
accept-language | Proxy 中解析 Accept-Language 请求头 |
配置中心:src/i18n/settings.ts
这是整个 i18n 系统的唯一配置入口,所有其他模块都从这里导入。
import type { CustomTypeOptions, InitOptions } from 'i18next'
import resourcesToBackend from 'i18next-resources-to-backend'
export const LANGUAGES = ['en', 'zh'] as const
export type Language = (typeof LANGUAGES)[number]
export type Namespace = keyof CustomTypeOptions['resources']
export const DEFAULT_LNG: Language = 'en'
export const DEFAULT_NS: Namespace = 'common'
export const LANGUAGE_COOKIE = 'i18n_lang'
// 共享 backend——server.ts 和 client.ts 复用同一个实例
export const backend = resourcesToBackend(
(locale: string, namespace: string) =>
import(`@/i18n/language/${locale}/${namespace}.json`)
)
// TypeScript 类型守卫——运行时校验未知输入
export function isValidLocale(value: unknown): value is Language {
return LANGUAGES.includes(value as Language)
}
// 统一的 i18next 初始化配置——服务端和客户端共享
export function getI18nOptions(
lng: Language = DEFAULT_LNG,
ns: Namespace = DEFAULT_NS
): InitOptions {
return {
lng,
fallbackLng: DEFAULT_LNG,
supportedLngs: LANGUAGES as unknown as string[],
ns,
fallbackNS: DEFAULT_NS,
defaultNS: DEFAULT_NS,
interpolation: { escapeValue: false },
returnNull: false // 找不到 key 时返回 key 字符串,而非空白的 null
}
}这个文件承载的设计决策:
- 共享
backend:一个resourcesToBackend实例,导出一次,server.ts和client.ts都导入它——不复重实例化 isValidLocale()类型守卫:不只是 TypeScript 编译期的类型检查(value is Language),它真正在运行时校验输入。Proxy 和 Server Action 都用它returnNull: false:翻译 key 缺失时不返回null(渲染空白),而是返回 key 字符串本身(比如"nav.missingKey"),开发时一眼就能发现漏翻译的地方Namespace类型自动推导:keyof CustomTypeOptions['resources']——你在i18next.d.ts加一行,所有用到getT<N>()和useTranslation()的地方自动识别新 namespace
TypeScript 类型安全
通过模块增强让 t() 具备 IDE 自动补全和编译期校验。
import 'i18next'
declare module 'i18next' {
interface CustomTypeOptions {
defaultNS: 'common'
resources: {
common: typeof import('@/i18n/language/en/common.json')
home: typeof import('@/i18n/language/en/home.json')
about: typeof import('@/i18n/language/en/about.json')
blog: typeof import('@/i18n/language/en/blog.json')
project: typeof import('@/i18n/language/en/project.json')
message: typeof import('@/i18n/language/en/message.json')
poems: typeof import('@/i18n/language/en/poems.json')
photos: typeof import('@/i18n/language/en/photos.json')
use: typeof import('@/i18n/language/en/use.json')
auth: typeof import('@/i18n/language/en/auth.json')
}
}
}新增 namespace 只需两步:
- 创建
src/i18n/language/en/{name}.json和src/i18n/language/zh/{name}.json - 在
i18next.d.ts加一行typeof import(...)
不需要手动更新任何 union type,Namespace 类型自动跟进来。
翻译文件
按 namespace 划分——每个页面或功能区域对应一个 JSON 文件,配合 resourcesToBackend 的动态 import() 实现按页面的代码分割。
src/i18n/language/
├── en/
│ ├── common.json # 导航、页脚、搜索框、404、通用 UI
│ ├── home.json # 首页
│ ├── about.json # 关于页
│ ├── blog.json # 博客
│ └── ...
└── zh/
├── common.json
├── home.json
├── about.json
├── blog.json
└── ...{
"nav": {
"blog": "博客",
"project": "项目",
"about": "关于"
},
"footer": {
"copyright": "© 2025-至今 King3. 保留所有权利。"
},
"localeSwitcher": {
"label": "切换语言"
}
}proxy.ts——语言检测与 Cookie 回写
Next.js 16 中 proxy.ts 替代了 middleware.ts,是每个匹配请求的第一道关卡。
我们的 proxy 做三件事:检测语言、回写 Cookie、守卫 admin 路由。
关键设计:persisted 标记
每个 i18n middleware 都要回答一个问题:当前请求该用什么语言?但还有一个更隐蔽的问题:这个语言是我们从 Cookie 里读到的已有偏好,还是从 Accept-Language 刚推断出来的?
- Cookie 存在且合法 → 直接用,
persisted: true,什么都不用写 - Cookie 不存在 → 从
Accept-Language推断,立即把结果写回 Cookie,这样后续的 Server Components 和客户端 JS 都能读到一致的值
如果漏掉这个回写会发生什么? Proxy 从 Accept-Language 推断出 zh,但 server.ts 去读 cookies() 时发现什么都没有,fallback 到 en。结果:proxy 认为当前请求是中文,但服务端渲染出的内容是英文——用户第一次打开就看到错的语言。
import type { NextRequest } from 'next/server'
import type { Language } from '@/i18n/settings'
import acceptLanguage from 'accept-language'
import { NextResponse } from 'next/server'
import {
DEFAULT_LNG,
isValidLocale,
LANGUAGE_COOKIE,
LANGUAGES
} from '@/i18n/settings'
import { ADMIN_USER_ROLE } from '@/lib/auth'
import { getServerSession, requireServerAdminSession } from '@/lib/auth-session'
acceptLanguage.languages([...LANGUAGES])
// ── 语言检测 ──
function detectLocale(request: NextRequest): {
locale: Language
persisted: boolean
} {
// 1. Cookie 优先
const saved = request.cookies.get(LANGUAGE_COOKIE)?.value
if (saved && LANGUAGES.includes(saved as Language)) {
return { locale: saved as Language, persisted: true }
}
// 2. 降级到 Accept-Language
const matched = acceptLanguage.get(request.headers.get('Accept-Language'))
return {
locale: isValidLocale(matched) ? matched : DEFAULT_LNG,
persisted: false // ← 需要回写
}
}
function withLocale(
response: NextResponse,
locale: Language,
persisted: boolean
) {
if (!persisted) {
response.cookies.set(LANGUAGE_COOKIE, locale, {
path: '/',
maxAge: 60 * 60 * 24 * 7, // 7 天
sameSite: 'lax'
})
}
return response
}
// ── Proxy 主逻辑 ──
export async function proxy(request: NextRequest) {
const { pathname } = request.nextUrl
const { locale, persisted } = detectLocale(request)
const reply = (response: NextResponse) =>
withLocale(response, locale, persisted)
const redirect = (path: string) =>
reply(NextResponse.redirect(new URL(path, request.url)))
// Admin 鉴权守卫
if (pathname.startsWith('/admin')) {
try {
await requireServerAdminSession(request.headers)
} catch {
return redirect('/')
}
}
// /auth 页面:已登录用户重定向
if (pathname === '/auth') {
const session = await getServerSession(request.headers)
if (session) {
const redirectPath =
session.user.role === ADMIN_USER_ROLE ? '/admin' : '/'
return redirect(redirectPath)
}
}
return reply(NextResponse.next())
}
export const config = {
matcher: [
'/((?!api|_next/static|_next/image|favicon.ico|icons|images|fonts).*)'
]
}语言优先级: Cookie → Accept-Language → 'en'。
为什么不需要自定义 Header: 很多 i18n middleware 实现会注入一个 x-i18n-lang 请求头,让 Server Components 通过 headers() 读取。但 Cookie 已经被写入响应了——Server Components 直接用 cookies() 读就行,多一层 Header 中转反而增加了状态通路,还容易不一致。Cookie 就是唯一的真相来源。
服务端翻译——getT() 和 per-request 实例隔离
Server Components 不能用 React Hooks,所以需要一个 async 工具函数。
为什么每次调用都 createInstance()?
Next.js App Router 在服务端是并发处理请求的。假设两个请求同时到达——一个中文用户、一个英文用户——如果 getT() 使用全局单例 i18next 实例,后一个 init() 调用会覆盖前一个的语言状态。这就是典型的并发环境下的共享可变状态 bug。
createInstance() 给每个请求自己的隔离实例,互不干扰。性能开销可以忽略——i18next 实例本身很轻量,而加载 JSON 翻译文件的成本已经被模块系统的 import() 缓存覆盖了。
'use server'
import type { Language, Namespace } from './settings'
import { createInstance } from 'i18next'
import { cookies } from 'next/headers'
import { initReactI18next } from 'react-i18next/initReactI18next'
import {
backend, // 共享 backend
DEFAULT_NS,
DEFAULT_LNG,
getI18nOptions,
LANGUAGE_COOKIE
} from './settings'
async function initI18next(lng: Language, ns: Namespace) {
const instance = createInstance()
instance.use(initReactI18next)
instance.use(backend)
await instance.init(getI18nOptions(lng, ns))
return instance
}
/**
* Server Components / generateMetadata 的翻译入口。
*
* @example
* const { t, lang } = await getT('about')
* return <h1>{t('title')}</h1>
*/
export async function getT<N extends Namespace = typeof DEFAULT_NS>(
ns: N = typeof DEFAULT_NS as N
) {
const lang = await getLang()
const i18next = await initI18next(lang, ns)
return {
t: i18next.getFixedT(lang, ns),
lang,
i18next
}
}
// 独立工具函数——不创建 i18next 实例就能拿到当前语言。
// 适合 generateMetadata 或服务端条件逻辑。
export async function getLang() {
const cookie = await cookies()
const lang = (cookie.get(LANGUAGE_COOKIE)?.value ?? DEFAULT_LNG) as Language
return lang
}几个关键点:
- 通过
cookies()直接读取 Cookie,不走 Header 中转——proxy 已经写好了 Cookie,server 只管读 - 把
getLang()单独暴露——有些场景只需要知道当前语言(比如 metadata 的条件逻辑),不需要创建完整的 i18next 实例 - 每次
getT()调用都是独立的createInstance()——确保并发请求的语言状态不会交叉污染
根布局——动态 lang 属性
generateMetadata() 和 RootLayout 分别调用 getT() 和 getLang(),设置 <html lang> 和翻译后的 metadata:
import type { Metadata } from 'next'
import { getLang, getT } from '@/i18n/server'
export async function generateMetadata(): Promise<Metadata> {
const { t } = await getT('common')
return {
title: {
default: 'King3',
template: '%s - King3'
},
description: t('metadata.root.description'),
icons: { icon: '/icons/favicon.svg' }
}
}
export default async function RootLayout({
children
}: {
children: React.ReactNode
}) {
const lang = await getLang()
return (
<html lang={lang} suppressHydrationWarning>
<body className="min-h-screen scroll-smooth antialiased">
{/* Providers ... */}
{children}
</body>
</html>
)
}<html lang> 和翻译后的 <meta description> 来自同一个 Cookie,天然同步。
客户端初始化
Client Components 使用全局 i18next 单例 + LanguageDetector,检测链从 Cookie 开始。
'use client'
import i18next from 'i18next'
import LanguageDetector from 'i18next-browser-languagedetector'
import {
initReactI18next,
useTranslation as useI18nextTranslation
} from 'react-i18next'
import { backend, getI18nOptions, LANGUAGE_COOKIE, LANGUAGES } from './settings'
const runsOnServer = typeof window === 'undefined'
i18next
.use(initReactI18next)
.use(LanguageDetector)
.use(backend) // 共享 backend
.init({
...getI18nOptions(),
lng: undefined, // 不硬编码——完全交给 LanguageDetector
detection: {
order: ['cookie', 'navigator'],
lookupCookie: LANGUAGE_COOKIE,
caches: ['cookie'] // 检测结果回写到 Cookie
},
// SSR 阶段预加载所有语言——保证 hydration 输出一致。
// CSR 阶段按需加载。
preload: runsOnServer ? (LANGUAGES as unknown as string[]) : []
})
const useTranslation = useI18nextTranslation
export { useTranslation }
export default i18next检测优先级:
- Cookie(
i18n_lang)——proxy 在首次访问时写入,或语言切换时更新 - Navigator(
navigator.language)——浏览器偏好作为兜底
lng: undefined 非常关键——如果这里设了一个硬编码值(比如 'en'),LanguageDetector 的检测链就完全被覆盖了。
useTranslation 别名让所有组件从同一个路径导入(@/i18n/client),不管单例有没有初始化完成。
语言切换——Server Action + 客户端更新 + RSC 刷新
语言切换组件需要协调三件事:服务端持久化偏好(Server Action)、客户端组件立即更新(i18n API)、服务端组件重新渲染(router.refresh)。
为什么用 Server Action 而不是 document.cookie?
document.cookie 是同步 API、字符串拼接方式操作、纯客户端执行。它可能和 Next.js App Router 内部的 Cookie 处理产生竞态。Server Action 通过 cookies().set() 写 Cookie——跟 proxy 和 server components 用的是同一套 API——配合输入校验(isValidLocale())和结构化的错误处理(ResponseResult),比直接操作 document.cookie 可靠得多。
'use server'
import { cookies } from 'next/headers'
import { isValidLocale, LANGUAGE_COOKIE } from '@/i18n/settings'
import { failure, success } from '@/lib/result'
export async function switchLocaleAction(value: unknown) {
if (!isValidLocale(value)) {
return failure(new Error(`不支持的语言: "${value}"`))
}
try {
const cookie = await cookies()
cookie.set(LANGUAGE_COOKIE, value, {
path: '/',
maxAge: 60 * 60 * 24 * 7,
sameSite: 'lax'
})
return success(null)
} catch (error: unknown) {
return failure(error)
}
}为什么参数类型是 unknown 而不是 Language? Server Action 不只是被你的 React 组件调用——它本质上是一个 HTTP endpoint,可以被 fetch()、第三方脚本、curl 直接调。TypeScript 的类型在运行时是不存在的。用 unknown + isValidLocale() 类型守卫,保证运行时的校验不依赖编译期的类型系统。
Cookie 的参数(path、maxAge、sameSite)和 proxy 的 withLocale() 完全一致——所有写 Cookie 的地方统一标准。
'use client'
import type { Language } from '@/i18n/settings'
import { useRouter } from 'next/navigation'
import { useEffect, useState } from 'react'
import { toast } from 'sonner'
import { switchLocaleAction } from '@/app/actions/i18n'
import { InteractiveIcon, LanguageIcon } from '@/components/icons'
import { useTranslation } from '@/i18n/client'
function ToggleLanguage() {
const router = useRouter()
const { i18n, t } = useTranslation()
const currentLang = (i18n.language ?? 'en') as Language
const [mounted, setMounted] = useState(false)
useEffect(() => setMounted(true), [])
const switchLang = async (event: React.MouseEvent) => {
event.preventDefault()
const lang: Language = currentLang === 'en' ? 'zh' : 'en'
// 1. Server Action — 服务端持久化 Cookie
const result = await switchLocaleAction(lang)
// 2. i18n API — 客户端组件立即切换(不用等刷新)
i18n.changeLanguage(lang)
// 3. router.refresh — RSC 树重新渲染,server.ts 读到新 Cookie
if (result.success) {
router.refresh()
} else {
toast.error(result.message)
}
}
// SSR 阶段:渲染静态占位图标,避免 hydration mismatch
if (!mounted) {
return (
<InteractiveIcon trigger="hover">
{() => <LanguageIcon isEN isHovered={false} />}
</InteractiveIcon>
)
}
const isEN = currentLang === 'en'
return (
<InteractiveIcon
alt={t('localeSwitcher.label')}
trigger="hover"
onClick={switchLang}
>
{({ isHovered }) => <LanguageIcon isEN={isEN} isHovered={isHovered} />}
</InteractiveIcon>
)
}切换三步走:
- Server Action——服务端写 Cookie,持久化偏好
i18n.changeLanguage()——客户端实例立即切换,用户无需等待刷新router.refresh()——RSC 树重新渲染,Server Components 通过getLang()读到新 Cookie
SSR 安全性(mounted 守卫): SSR 阶段 i18n.language 还没解析出来(Node 环境里没有 Cookie)。没有 mounted 保护的话,服务端按 isEN = true 渲染,客户端 hydration 后发现实际是 zh,就会出现 hydration mismatch。mounted 状态在 SSR 阶段先渲染一个固定的 isEN 占位图标,hydration 完成后再渲染真实语言对应的图标。
错误处理: Server Action 返回 ResponseResult。Cookie 写失败了,toast.error() 弹出提示——不会让用户以为切换成功了但其实没生效。
翻译使用方式
服务端组件:
import { getT } from '@/i18n/server'
export default async function Home() {
const { t } = await getT('home')
return (
<div>
<h2>{t('latestUpdates')}</h2>
{/* ... */}
</div>
)
}客户端组件:
'use client'
import { useTranslation } from '@/i18n/client'
function ErrorComponent() {
const { t } = useTranslation('common')
return <h1>{t('error.title', '出错了')}</h1>
}generateMetadata:
import type { Metadata } from 'next'
import { getT } from '@/i18n/server'
export async function generateMetadata(): Promise<Metadata> {
const { t } = await getT('about')
return {
title: t('title'),
description: t('description')
}
}速查:
| 场景 | 用法 |
|---|---|
| Server Component | const { t } = await getT('ns') |
| Client Component | const { t } = useTranslation('ns') |
generateMetadata | const { t } = await getT('ns') |
| Server Action | const { t } = await getT('ns') |
| 仅需语言 | const lang = await getLang() |
Hydration 一致性
i18n 最容易踩的坑就是 hydration mismatch——服务端渲染 A 语言、客户端 hydration 用的是 B 语言。这个方案里两端都从同一个 Cookie 派生:
- 服务端:
getLang()→cookies().get('i18n_lang')→ 读到 Cookie 值 - 客户端:
LanguageDetector→order: ['cookie', 'navigator']→ 先读 Cookie
没有自定义 Header、没有独立的状态通道——同一个 Cookie,两端天然一致。
首次访问完整链路
用户第一次打开网站(浏览器没有 i18n_lang Cookie,但浏览器语言是中文):
- proxy.ts:
detectLocale()→ Cookie 为空 → 解析Accept-Language→ 匹配到zh→persisted: false - proxy.ts:
withLocale()→ 写入Set-Cookie: i18n_lang=zh到响应头 - 服务端渲染:
getLang()→cookies()读到zh→ RSC 输出中文 HTML - 客户端 hydration:
LanguageDetector→ 读取 Cookie →zh→ i18next 初始化中文 - 结果:用户看到的第一屏就是中文,从 SSR HTML 到客户端 hydration 全程一致
如果省掉第 2 步(不写 Cookie 回写):
- 第 3 步
getLang()读 Cookie → 空 → fallbacken→ SSR 输出英文 HTML - 第 4 步客户端
LanguageDetector→ Cookie 空 → 读navigator.language→zh→ hydration 时中文 - 结果:第一屏 SSR HTML 是英文,hydration 后瞬间变中文——明显的页面闪烁
这就是 persisted 标记和 Cookie 回写的全部意义。
按需加载
i18next-resources-to-backend 通过动态 import() 按页面加载翻译文件:
- 访问首页 → 加载
common.json+home.json - 访问 about → 加载
common.json+about.json - 没访问过的页面的翻译文件永远不会被下载
server.ts 和 client.ts 共享同一个 backend 实例(从 settings.ts 导入),按需加载的策略在两套环境里保持一致。
总结
src/
├── i18n/
│ ├── settings.ts # 共享配置、backend、类型守卫、init options
│ ├── server.ts # getT() + getLang()——Server Components 翻译
│ └── client.ts # useTranslation——Client Components 翻译
├── i18n/language/
│ ├── en/{namespace}.json # 英文翻译(10 个 namespace)
│ └── zh/{namespace}.json # 中文翻译(10 个 namespace)
├── types/
│ └── i18next.d.ts # TypeScript 模块增强——编译期 key 校验
├── app/actions/
│ └── i18n.ts # switchLocaleAction——Server Action 写 Cookie
├── components/layout/Header/
│ └── ToggleLanguage.tsx # 语言切换 UI
└── proxy.ts # detectLocale + withLocale + persisted 标记
这套架构的核心价值:
- 零路由改造——没有
[locale]动态段,现有链接一个不改 - Cookie 是唯一真相来源——没有自定义 Header、没有状态副本
- 首次访问一致性——proxy 回写 Cookie,服务端和客户端从第一次请求就对齐
- per-request 隔离——
createInstance()杜绝服务端并发请求间的语言交叉污染 - 可靠的语言切换——Server Action 持久化 Cookie + 错误提示,不是静默失败
- 完整 TypeScript 类型——所有翻译 key 编译期校验 + IDE 自动补全
- 按需加载——只下载当前页面的翻译 JSON