☰
next-i18next v16 App Router 无语言前缀实战:基于 Cookie 的语言检测与切换方案
2026/10/15 10:04:25 网站建设 项目流程
  • 前端
  • 后端

【免费下载链接】next-i18next

The easiest way to translate your NextJs apps.

项目地址:https://gitcode.com/gh_mirrors/ne/next-i18next
点击查看免费下载

本指南以仓库中的 app-router-no-locale-path 示例 为主体,系统讲解 next-i18next v16 在 Next.js App Router 下实现"URL 中不出现语言前缀"的国际化方案:地址保持/about而非/en/about,语言由 Cookie 记忆、由 Proxy 中间件检测,并通过useChangeLanguage()完成切换。读完本文,你将掌握getT()/useT()的服务器与客户端渲染用法、createProxy()的 Cookie 检测链路,以及I18nProvider水合与语言同步的完整原理。

一、为什么需要"无语言前缀"方案

在 App Router 场景下,next-i18next 的默认做法(localeInPath: true)会在 URL 中加入语言段,例如/en/about、/de/about,由[lng]动态段承载语言参数。而"无语言前缀"模式(localeInPath: false)选择:

  • URL 保持干净:/about而不是/en/about,对 SEO 与分享链接更友好;
  • 语言切换基于 Cookie:用户选择的语言写入 Cookie,刷新、跳转后仍被记住;
  • 不产生 URL 重定向:Proxy 中间件只负责检测语言并注入请求头,不做 302 跳转。

该示例明确演示了如下核心能力(见 README):

  • 服务端组件使用getT()(来自next-i18next/server);
  • 客户端组件使用useT()(来自next-i18next/client);
  • 基于 Cookie 的语言检测由 proxy.js 中的createProxy()完成,无 URL 重定向;
  • useChangeLanguage()钩子负责切换语言;
  • I18nProvider负责客户端水合。

二、快速开始

进入示例目录并启动开发服务器(package.json 中定义了dev/build/start等脚本):

npm install npm run dev

示例同时提供npm run build与npm start用于生产构建与启动。值得注意的依赖版本组合(package.json):next-i18next ^16.0.10、next ^16.3.1、i18next ^26.4.0、react-i18next ^17.0.12、react ^19.2.8,其中next-i18next16.x 是针对 App Router 与 Next 16proxy.ts的当代实现。

项目结构概览:

examples/app-router-no-locale-path/ ├── i18n.config.js # i18next 核心配置(含 localeInPath: false) ├── proxy.js # createProxy 中间件入口 ├── next.config.js # 纯 Next 配置(无 i18n 相关改动) └── app/ ├── layout.js # 根布局:initServerI18next + I18nProvider ├── page.js # 服务端组件首页 ├── client-page/ # 客户端组件页(useT) ├── second-page/ # 服务端组件页(getT) ├── second-client-page/ # 客户端组件页(useT) ├── components/ # Header / Footer(服务端版 + 客户端版 + LangSelect) └── i18n/locales/ # en / de / it 三语资源

三、核心配置:i18n.config.js 逐项解读

i18n.config.js 是整个方案的"总开关":

// 开发模式从磁盘读取,使 reloadOnPrerender 无需重启 dev server 即可感知修改; // 生产模式使用可被打包器追踪的 import(),让语言文件进入 serverless 函数产物。 const resourceLoader = process.env.NODE_ENV === 'development' ? async (language, namespace) => { const fs = await import('fs/promises') const path = await import('path') const content = await fs.readFile( path.resolve(process.cwd(), `app/i18n/locales/${language}/${namespace}.json`), 'utf-8' ) return JSON.parse(content) } : (language, namespace) => import(`./app/i18n/locales/${language}/${namespace}.json`) /** @type {import('next-i18next/proxy').I18nConfig} */ const i18nConfig = { supportedLngs: ['en', 'de', 'it'], fallbackLng: 'en', defaultNS: 'translation', localeInPath: false, ns: ['translation', 'footer', 'client-page', 'second-page', 'second-client-page'], resourceLoader, reloadOnPrerender: process.env.NODE_ENV === 'development', } module.exports = i18nConfig

各字段含义与底层行为如下(类型定义见 src/appRouter/types.ts):

配置项本示例取值说明
supportedLngs['en', 'de', 'it']支持的语言列表,用于语言检测匹配与<html lang>输出
fallbackLng'en'兜底语言;检测不到任何匹配时使用
defaultNS'translation'默认命名空间;normalizeConfig 中缺省为'common'
ns5 个命名空间全部已知命名空间,缺省为[defaultNS]
localeInPathfalse关键开关:false表示 URL 不含语言段,见下文
resourceLoader双模式函数自定义资源加载器,覆盖默认的localePath文件读取
reloadOnPrerender仅开发模式每次预渲染前重载资源,实现改语言文件即时生效

3.1resourceLoader:开发与生产的双轨设计

该函数是示例中最有工程价值的设计之一,注释中解释了动机:开发模式下 bundler 会跨 HMR 周期缓存动态import()的 JSON 模块,导致首次编辑后热更新失效;因此开发环境直接fs.readFile从磁盘读取,配合reloadOnPrerender: true实现改文件即生效。生产环境则必须使用可被打包器追踪的import(),否则语言文件不会被打进 serverless 函数产物(Vercel / Lambda 等平台上public/下的文件经 CDN 分发,运行时不保证存在于文件系统)。

这与 src/appRouter/server.ts 中createResourceBackend的逻辑一致:若配置了resourceLoader,则包装进i18next-resources-to-backend;否则走默认的文件系统读取,并在 serverless/Edge 环境下抛出引导性错误,提示改用resourceLoader动态导入方案。

3.2localeInPath: false意味着什么

在 src/appRouter/types.ts 中,localeInPath的类型是boolean | 'internal',默认值为true(见 normalizeConfig)。置为false后,Proxy 中间件走"无语言路径模式"分支——不做任何重定向与重写,只把检测到的语言写入自定义请求头,见下一节。

四、createProxy 中间件:Cookie 优先的语言检测

proxy.js 只有十余行:

import { createProxy } from 'next-i18next/proxy' import i18nConfig from './i18n.config' export const proxy = createProxy(i18nConfig) export const config = { matcher: ['/((?!api|_next/static|_next/image|assets|favicon.ico|sw.js|site.webmanifest).*)'], }

createProxy返回一个标准 Next.js 中间件函数(源码见 src/appRouter/proxy/index.ts),并在 proxy/index.ts 中导出createMiddleware作为兼容别名。matcher负责排除 API 路由、静态资源与站点元数据,避免中间件干扰这些路径。

4.1 语言检测的三级回退

中间件内部按以下顺序解析语言(src/appRouter/proxy/index.ts):

  1. Cookie 优先:读取cookieName(默认i18next)对应的值,若存在则匹配到supportedLngs;
  2. Accept-Language 头:无有效 Cookie 时,解析请求的Accept-Language头(languageDetector.ts 中parseAcceptLanguage负责按 q 值排序,matchLanguage按"精确匹配 → 前缀匹配 →(可选)反向区域匹配"三级规则选择);
  3. fallbackLng 兜底:两者都无效时返回配置的fallbackLng。

值得注意的细节:Cookie 只会在"语言实际发生变化"时才写入响应(persistLanguage判断req.cookies.get(cookieName)?.value === lng则跳过),注释解释了原因——Next 会把中间件设置的 Cookie 合并进请求的可变 Cookie,任何被修改的 Cookie 都会触发重新验证,若无条件写入会导致每个 Server Action 都重新拉取整页。

4.2 无语言路径模式:只设头、不跳转

关键分支在 src/appRouter/proxy/index.ts:

} else { // No-locale-path mode: don't redirect, just set the header const headers = new Headers(req.headers) headers.set(config.headerName, lng) const response = NextResponse.next({ request: { headers } }) return response }

检测结果通过headerName(默认x-i18next-current-language)注入请求头,交由服务端渲染链路消费。测试 createProxy.test.ts 验证了这一行为:请求/about时不触发重定向,且x-i18next-current-language被正确设为en。

五、服务端初始化与根布局:I18nProvider 水合

app/layout.js 串联起服务端初始化与客户端水合:

import './global.css' import { dir } from 'i18next' import { initServerI18next, getT, getResources } from 'next-i18next/server' import { I18nProvider } from 'next-i18next/client' import i18nConfig from '../i18n.config' initServerI18next(i18nConfig) export async function generateMetadata() { const { t } = await getT() return { title: t('title'), description: 'A playground to explore new Next.js app directory features such as nested layouts, instant loading states, streaming, and component level data fetching.' } } export default async function RootLayout({ children }) { const { t, i18n, lng } = await getT() const resources = getResources(i18n) return ( <I18nProvider language={lng} resources={resources} supportedLngs={i18nConfig.supportedLngs}> <html lang={lng} dir={dir(lng)}> <head /> <body> {children} </body> </html> </I18nProvider> ) }

几个关键点:

  • initServerI18next(i18nConfig)在模块作用域执行一次,初始化模块级共享实例;源码 src/appRouter/server.ts 显示重复调用只会替换配置、保留共享实例。若想避开模块求值顺序问题,可改用createServerI18next(config)返回绑定配置的{ getT, getResources, generateI18nStaticParams }(src/appRouter/server.ts)。
  • getT()返回{ t, i18n, lng }(GetTResult,见 types.ts),lng是本次请求解析出的语言。
  • getResources(i18n)从共享实例的 store 抽取全部已加载资源,序列化进 HTML 供客户端水合;源码 server.ts 支持按namespaces与languages参数裁剪,多语言项目可用它控制首屏载荷。
  • I18nProvider接收language、resources、supportedLngs三个属性完成客户端实例水合;dir(lng)用于设置文档方向(如阿拉伯语 RTL)。
  • 无语言前缀模式下没有params.lng,因此getT()的语言来源是请求头(而非 URL 参数),这正是 Proxy 中间件注入的x-i18next-current-language。

5.1 服务端检测的完整顺序

src/appRouter/server.ts 的detectLanguage展示了服务端语言解析顺序:

  1. 根路由参数(next/root-params,Next 16.3+):若当前布局存在[lng]/[locale]段则优先取用,且读取 root params 不破坏静态预渲染(区别于会 opt-out 的headers()/cookies());
  2. 自定义请求头:读取 Proxy 注入的headerName;
  3. Cookie:读取cookieName并校验是否在supportedLngs中(支持nonExplicitSupportedLngs的区域匹配);
  4. fallbackLng 兜底。

该函数以 React 的cache()包裹,同一渲染周期内多次getT只做一次检测。

六、服务端组件翻译:getT

首页 app/page.js 是典型的服务端组件用法:

import Link from 'next/link' import { Trans } from 'react-i18next/TransWithoutContext' import { Header } from './components/Header' import { Footer } from './components/Footer' import { getT } from 'next-i18next/server' export default async function Page() { const { t, i18n } = await getT() return ( <> <main> <Header heading={t('h1')} /> <h2> <Trans t={t} i18n={i18n} i18nKey="welcome"> Welcome to Next.js 16 <small>with the App Router</small> and next-i18next </Trans> </h2> ... </main> <Footer/> </> ) }

要点:

  • getT()默认使用defaultNS(translation),也可传命名空间参数,如await getT('second-page');
  • 服务端组件中无法使用依赖 Context 的<Trans>,因此示例采用react-i18next/TransWithoutContext的Trans,并显式传入t与i18n;
  • t('welcome')对应的资源中内嵌 HTML 占位符(见 en/translation.json 的"welcome": "Welcome to Next.js 16 <1>with the App Router</1> and next-i18next"),<Trans>的默认子节点与i18nKey配合完成内联元素映射。

generateMetadata同样可以调用await getT()取t('title'),服务端页面(如 second-page/page.js)与布局(second-page/layout.js)的用法完全一致。

七、客户端组件翻译:useT

app/client-page/page.js 演示客户端组件与复数翻译:

'use client' import Link from 'next/link' import { useT } from 'next-i18next/client' import { Header } from '../components/Header' import { Footer } from '../components/Footer/client' import { useState } from 'react' export default function Page() { const { t } = useT('client-page') const [counter, setCounter] = useState(0) return ( <> <main> <Header heading={t('h1')} /> <p>{t('counter', { count: counter })}</p> ... </main> <Footer path="/client-page" /> </> ) }

对应资源 en/client-page.json 展示了 i18next 复数规则(counter_zero/counter_one/counter_other),t('counter', { count })会自动选择正确形式。useT源码见 src/appRouter/client.tsx:它封装react-i18next的useTranslation,并在localeInPath模式下同步 URL 参数语言;无语言路径模式下则直接使用I18nProvider设定好的语言。

八、语言切换:useChangeLanguage + LangSelect

语言切换器 LangSelect.js 是客户端组件:

"use client" import { useChangeLanguage } from 'next-i18next/client' import { useRouter } from "next/navigation" // languages 来自 i18next 实例(i18n.options.supportedLngs),而不是导入 i18n.config: // 该配置是服务端代码(携带 resourceLoader),导入它会把它带进浏览器 bundle。 export const LangSelect = ({ currentLanguage, languages = [] }) => { const router = useRouter() const changeLanguage = useChangeLanguage() // i18next 会把调试语言 'cimode' 追加进 supportedLngs const options = languages.filter((lang) => lang !== 'cimode' && lang !== currentLanguage) const handleChangeLanguage = (e, lang) => { e.preventDefault() changeLanguage(lang) router.refresh() } return ( <div> { options.map((lang) => { return ( <span key={lang}> <button onClick={(e) => handleChangeLanguage(e, lang)} type="button">{lang}</button> </span> ) }) } </div> ) }

注释揭示了两个重要细节:

  • 语言列表来自i18n.options.supportedLngs(由FooterBase传入i18n.options.supportedLngs,见 FooterBase.js),而非直接导入i18n.config——后者是携带resourceLoader的服务端代码,导入会污染浏览器 bundle;
  • 切换流程是changeLanguage(lang)写 Cookie + 更新 i18next 实例,随后router.refresh()触发服务端重渲染,让新语言的资源随下一次响应送达。

8.1 useChangeLanguage 的底层实现

源码 src/appRouter/client.tsx 展示了该钩子的完整逻辑:

export function useChangeLanguage(cookieName = 'i18next', cookieOptions: CookieOptions = {}) { const { i18n } = useTranslation() const router = useRouter() const { domain, secure, sameSite = 'lax', path = '/', maxAge = 365 * 24 * 60 * 60 } = cookieOptions return useCallback(async (newLng: string) => { let cookie = `${cookieName}=${newLng};path=${path};max-age=${maxAge};SameSite=${sameSite[0].toUpperCase()}${sameSite.slice(1)}` if (domain) cookie += `;domain=${domain}` if (secure) cookie += ';Secure' document.cookie = cookie if (i18n.getDataByLanguage(newLng)) await i18n.changeLanguage(newLng) router.refresh() }, [i18n, router, cookieName, domain, secure, sameSite, path, maxAge]) }

要点:

  • 默认 Cookie 名i18next,maxAge一年,与 Proxy 的cookieMaxAge默认值(365 * 24 * 60 * 60秒,见 config.ts)对齐;
  • cookieOptions(domain/secure/sameSite/path/maxAge)与 Proxy 的cookieOptions使用同一类型(types.ts),保证两边写 Cookie 的作用域一致;
  • 只有当目标语言资源已存在于实例 store 时才立即changeLanguage,否则交给I18nProvider在router.refresh()送回资源后再切换——这是为了避免资源未到时语言被解析回 fallback 的竞态,对应的回归测试见 client.ondemand.test.tsx。

8.2 I18nProvider 的语言同步

src/appRouter/client.tsx 中I18nProvider的useEffect在language/resources变化时(导航或router.refresh()之后)增量合并服务端送来的资源包,并在有新资源或语言变化时调用instance.changeLanguage(language),确保切换后客户端立即渲染新语言而非 fallback。

九、共享 Footer 的服务端/客户端双实现

示例用 Footer/index.js(服务端版)与 Footer/client.js(客户端版)演示了"同一组件两种运行时"的惯用模式:

// index.js —— 服务端版(服务端组件页使用) import { FooterBase } from './FooterBase' import { getT } from 'next-i18next/server' export const Footer = async ({ path }) => { const { t, i18n, lng } = await getT('footer') return <FooterBase t={t} i18n={i18n} lng={lng} path={path} /> }
// client.js —— 客户端版(客户端组件页使用) 'use client' import { FooterBase } from './FooterBase' import { useT } from 'next-i18next/client' export const Footer = ({ path }) => { const { t, i18n } = useT('footer') return <FooterBase t={t} i18n={i18n} path={path} attachOnClick/> }

两版共用纯展示组件FooterBase,差别仅在翻译来源(getTvsuseT)与命名空间(footer)。footer.json中的description键注释道明"非页面组件需要自己的命名空间"(en/footer.json),languageSwitcher键则通过<Trans>的{{lng}}插值输出"Switch from X to: "。页面侧只需按运行时选../components/Footer或../components/Footer/client导入即可。

十、与 localeInPath 模式的对比与扩展

本示例把localeInPath置为false,是最"干净"的 URL 形态。若对照 app-router-simple 示例(同样配置supportedLngs、fallbackLng、defaultNS、resourceLoader、reloadOnPrerender,但走默认的[lng]动态段),可以总结三种形态(见 types.ts):

localeInPathURL 形态语言来源典型场景
true(默认)/en/about,[lng]段URL 参数多语言 SEO,每语言独立 URL
'internal'/about,内部重写到/{lng}/aboutProxy 检测 + 内部重写保持干净 URL 同时让每语言路由可静态预渲染
false(本示例)/about,无重写请求头(Cookie → Accept-Language → fallback)纯 Cookie 记忆,URL 完全不含语言

'internal'模式在 proxy/index.ts 中通过NextResponse.rewrite把干净 URL 重写到检测语言对应的内部路径,浏览器地址栏不变。hideDefaultLocale则适用于localeInPath: true时省略默认语言前缀的形态。三者的语言检测逻辑(Cookie → Accept-Language → fallback)完全一致,只是对 URL 的处理策略不同。

十一、配置参数速查表

以下参数均可通过I18nConfig配置(完整定义见 src/appRouter/types.ts),默认值见 normalizeConfig:

参数默认值作用
supportedLngs必填(或i18n.locales)支持语言列表
fallbackLng必填(或i18n.defaultLocale)兜底语言
defaultNS'common'默认命名空间
ns[defaultNS]已知命名空间列表
localeInPathtruefalse或'internal'时 URL 不含语言
localeParamName'lng'语言路由段名称(如'locale'对应app/[locale])
hideDefaultLocalefalse默认语言不加前缀(配合localeInPath: true)
localePath'/locales'语言文件相对public/的路径
localeStructure'{{lng}}/{{ns}}'目录结构模板
localeExtension'json'语言文件扩展名
resourceLoader内置文件/路径加载自定义资源加载函数
resources无预打包资源(提供后跳过动态加载)
cookieName'i18next'语言 Cookie 名
headerName'x-i18next-current-language'传递给服务端组件的语言头
cookieMaxAge365*24*60*60秒Cookie 有效期
persistCookietrue是否写 Cookie(他人管理系统持有时设false)
cookieOptions{}Cookie 附加属性(domain/secure/sameSite/path/maxAge)
ignoredPaths['/api','/_next','/static']中间件忽略的路径前缀
basePath无中间件只处理此前缀下的请求(混合路由场景)
reloadOnPrerender无开发模式渲染前重载资源
nonExplicitSupportedLngsfalse支持'en'匹配'en-US'的区域回退
use[]额外 i18next 插件(如 http/locize backend)
i18nextOptions{}合并进 init 的其余 i18next 选项

十二、测试验证与工程实践

仓库为这套机制提供了双端测试证据:

  • Proxy 行为:src/appRouter/proxy/createProxy.test.ts 中的'works in no-locale-path mode'用例断言请求/about时不触发重定向、x-i18next-current-language头正确写入;'falls back to default language when cookie has unsupported value'验证了 Cookie 值不合法时回退到 fallback;'skips ignored paths'与'skips static file extensions'验证中间件的白名单行为。
  • 客户端水合:src/appRouter/client.ondemand.test.tsx 用真实 i18next + react-i18next(无 mock)复现了router.refresh()后新语言资源到达、客户端从 fallback 恢复为正确语言的完整流程。

实际工程中还有两个可借鉴点:一是LangSelect中过滤cimode(i18next 追加的调试语言)避免渲染多余按钮;二是语言列表取自运行中的 i18next 实例而非直接导入服务端配置文件,保证浏览器 bundle 不被resourceLoader污染。

结语

无语言前缀方案让 URL 保持简洁,同时通过"Proxy 检测(Cookie → Accept-Language → fallback)+ 请求头传递 +useChangeLanguage写 Cookie 并router.refresh()"的闭环,实现了完全基于 Cookie 的语言记忆与切换。对照 app-router-no-locale-path 示例 的页面结构(服务端首页、客户端计数页、第二页、第二客户端页)与 i18n/locales 下的 en/de/it 三语资源,你可以快速落地一套"URL 无语言前缀"的 App Router 国际化应用;需要每语言独立 URL 做 SEO 时,仅需将localeInPath改回true(参考 app-router-simple),或改用'internal'兼得两者。

  • 前端
  • 后端

【免费下载链接】next-i18next

The easiest way to translate your NextJs apps.

项目地址:https://gitcode.com/gh_mirrors/ne/next-i18next
点击查看免费下载

相关推荐

上一篇:GetQzonehistory 使用指南:把QQ空间说说导出到本地
下一篇:stop-slop 去 AI 味指南:12 项自检清单,让 AI 文本发出前过一遍体检

创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询