--- createdAt: 2026-09-26 updatedAt: 2026-09-26 priority: 9 title: "Next.js 16 i18n 与 Lingui:App Router 配置指南" description: "在 Next.js 16 App Router 中配置 Lingui:Server Components、SWC 宏、proxy 路由、generateMetadata、hreflang、sitemap 和 robots.txt,附带基准测试数据。" keywords: - Lingui - LinguiJS - Next.js - Next.js 16 - App Router - React Server Components - 国际化 - i18n - SEO - 博客 slugs: - blog - nextjs-internationalization-using-lingui history: - version: 9.5.10 date: 2026-09-26 changes: "初始版本" author: aymericzip --- # 2026 年如何使用 Lingui 国际化你的 Next.js 应用 ## 目录 ## 什么是 Lingui? **Lingui** 是一个围绕**宏(macros)**和**消息提取(message extraction)**构建的 i18n 库。你在组件中编写源文本(`` t`Hello` ``、`Hello`),`lingui extract` 会将每条消息收集到语言目录(默认为 PO 文件)中,然后由加载器将它们编译为紧凑的 JavaScript。消息采用 ICU MessageFormat 语法,并且 Lingui 在 App Router 中支持 **React Server Components**。 本指南将在 **Next.js 16 App Router** 项目中配置 Lingui,包含以下内容: - **通过 SWC 编译宏**,确保 Turbopack 保持高速构建。 - **服务端组件与客户端组件**共享相同的 `Trans` 和 `useLingui` API。 - 通过 `proxy.ts` 实现**语言环境路由**:默认语言使用 `/about`,其他语言使用 `/fr/about`,并支持首次访问语言检测。 - 使用 `generateStaticParams` 对每个语言环境进行**静态渲染**。 - **完整的多语言 SEO 支持**:翻译后的 `generateMetadata`、canonical 规范链接、带 `x-default` 的 `hreflang`、Open Graph 本地化标签、JSON-LD、`sitemap.ts`、`robots.ts` 以及本地化的 404 页面。 > 想要了解其他国际化库?请参阅 [next-intl 指南](https://github.com/aymericzip/intlayer/blob/main/docs/blog/zh/i18n_using_next-intl.md)、[next-i18next 指南](https://github.com/aymericzip/intlayer/blob/main/docs/blog/zh/i18n_using_next-i18next.md) 或 [Next.js + Intlayer 指南](https://github.com/aymericzip/intlayer/blob/main/docs/docs/zh/intlayer_with_nextjs_16.md)。 > 正在使用 TanStack Start?请参阅 [TanStack Start + Lingui 指南](https://github.com/aymericzip/intlayer/blob/main/docs/blog/zh/i18n_using_tanstack-start_lingui.md)。对比不同方案?请阅读 [Lingui vs Intlayer](https://github.com/aymericzip/intlayer/blob/main/docs/blog/zh/lingui_vs_intlayer.md) 以及 [next-i18next vs next-intl vs Intlayer](https://github.com/aymericzip/intlayer/blob/main/docs/blog/zh/next-i18next_vs_next-intl_vs_intlayer.md)。 ## Next.js 上 Lingui 的基准测试表现 [i18n 基准测试](https://github.com/aymericzip/intlayer/blob/main/docs/docs/zh/benchmark/nextjs.md)在各大主流库上运行相同的包含 10 个页面和 10 种语言的 Next.js 应用,并测量浏览器实际下载的内容体积。 在 Next.js 16 上测试 `@lingui/core@6.6.0` 的关键数据,测量日期为 2026-09-26(gzip 压缩): | 配置方案 | 库体积 | 每页 JS 体积 | 其他语言泄露率 | 其他页面泄露率 | | :------------------------------- | ------: | -----------: | -------------: | -------------: | | 无 i18n(基础应用) | - | 141.0 KB | 0% | 0% | | Lingui,每个语言独立目录 | 72.1 KB | 145.4 KB | 2.8% | 89.9% | | `@intlayer/lingui`(兼容模式) | 10.7 KB | 221.6 KB | 50% | 90% | | `next-intlayer`(原生 Intlayer) | 4.9 KB | 141.5 KB | 0% | 0% | 核心结论: - **每个语言使用单一目录仍会将其他页面的消息泄露**到客户端 provider。尽量将文本保留在服务端组件中,因为服务端组件发送的是渲染好的 HTML,而不是消息目录。 - **Lingui 运行时体积约为 72 KB gzip。**`@intlayer/lingui` 兼容适配器将运行时体积减少到约 11 KB,但在此基准测试中,Next.js 兼容配置仍会将完整的消息目录发送到页面。原生 `next-intlayer` API 则是能保持基础应用原始体积的配置方案。 > 查看完整数据:[Next.js 基准测试报告](https://github.com/aymericzip/intlayer/blob/main/docs/docs/zh/benchmark/nextjs.md) 以及 [基准测试仓库](https://github.com/intlayer-org/benchmark-i18n)。 ## Next.js 上的功能特性对比 以下是 Next.js App Router 项目常用功能在 Lingui、`next-intl` 与 Intlayer 之间的对比: | 功能特性 | `next-intlayer` (Intlayer) | Lingui | `next-intl` | | ------------------------------------ | ------------------------------------------------ | --------------------------------------------- | ------------------------------------------- | | **组件就近存放翻译** | ✅ 内容与每个组件同目录放置 | ⚠️ 组件中编写源文本,语言目录集中管理 | ❌ 集中式 JSON | | **TypeScript 集成** | ✅ 自动生成严格类型 | ⚠️ 宏具有类型,但消息目录没有 | ✅ 优秀,通过 `AppConfig` 扩展 | | **缺失翻译检测** | ✅ TypeScript 错误与构建时警告 | ⚠️ 运行时回退到源文本 | ⚠️ 运行时回退 | | **富文本内容 (JSX, Markdown)** | ✅ 直接支持 | ✅ `` 内支持 JSX,不支持 Markdown | ⚠️ 通过 `t.rich` 支持标签,不支持 Markdown | | **AI 翻译** | ✅ 支持自定义服务商与 API Key,具备应用上下文 | ❌ 不支持 | ❌ 不支持 | | **可视化编辑器 / CMS** | ✅ 本地可视化编辑器 + 可选 CMS | ❌ 仅通过外部平台 | ❌ 仅通过外部平台 | | **本地化路由** | ✅ 开箱即用 | ❌ 需自行编写 `proxy.ts` | ✅ 内置 `[locale]` 路径段 | | **复数处理** | ✅ 基于枚举规则 | ✅ ICU 语法,`` 宏 | ✅ ICU 语法 | | **内容格式** | ✅ `.ts`, `.tsx`, `.js`, `.json`, `.md`, `.yaml` | ✅ PO, JSON, CSV | ✅ `.json`, `.js`, `.ts` | | **ICU MessageFormat** | ✅ 通过 `format: "icu"` 支持 | ✅ 原生支持 | ✅ 原生支持 | | **SEO 辅助工具 (hreflang, sitemap)** | ✅ 提供元数据、sitemap 与 robots.txt 辅助工具 | ❌ 需手动处理 | ✅ 良好 | | **服务端组件 (Server Components)** | ✅ 在任意服务端组件中直接访问 | ⚠️ 需在每个 layout 和 page 中调用 `setI18n` | ⚠️ 每个组件需调用 `await getTranslations()` | | **按组件进行 Tree-shaking** | ✅ 构建时完成 (Babel / SWC) | ⚠️ 每种语言单一目录,按页提取器仍处于实验阶段 | ⚠️ 需手动在每个路由使用 `pick()` | | **运行时体积 (gzip, 基准测试)** | 4.9 KB | 72.1 KB | 14.7 KB | | **CI 中检测缺失翻译** | ✅ `npx intlayer test` | ✅ `lingui compile --strict` | ⚠️ 非内置功能 | | **生态系统与社区** | ⚠️ 规模较小但增长迅速 | ✅ 成熟 | ✅ 庞大 | > 运行时体积来自 [Next.js 基准测试](https://github.com/aymericzip/intlayer/blob/main/docs/docs/zh/benchmark/nextjs.md)。更深入的讨论请阅读 [Lingui vs Intlayer](https://github.com/aymericzip/intlayer/blob/main/docs/blog/zh/lingui_vs_intlayer.md)。 > 其他 Next.js 指南:[next-intl](https://github.com/aymericzip/intlayer/blob/main/docs/blog/zh/i18n_using_next-intl.md)、[next-i18next](https://github.com/aymericzip/intlayer/blob/main/docs/blog/zh/i18n_using_next-i18next.md) 和 [Intlayer](https://github.com/aymericzip/intlayer/blob/main/docs/docs/zh/intlayer_with_nextjs_16.md)。 ## 你应该遵循的最佳实践 - **在 `[locale]` 布局中的 `` 上设置 `lang` 和 `dir`**。 - **优先在服务端组件中渲染文本**:它们在服务端生成 HTML,无需将消息目录发送到客户端。 - **在每个 layout 和 page 中调用 `initLingui(locale)`。**页面跳转时布局不会重新渲染,因此页面不能依赖布局来设置语言环境。 - **为每种语言保留独立 URL**,并使用 `generateStaticParams` 预渲染所有语言版本。 - **在 `generateMetadata` 中翻译元数据**,并配置 `canonical`、`hreflang` 和 `x-default`。 - **通过 `sitemap.ts` 和 `robots.ts` 约定生成多语言站点地图和 robots.txt**。 - **语言切换器使用真实的链接元素**,以便搜索引擎爬虫发现所有语言版本。 - **在 CI 中运行 `lingui extract`**,确保新消息不会在未翻译的情况下发布。 > 请参阅我们的[国际化与 SEO 指南](https://github.com/aymericzip/intlayer/blob/main/docs/blog/zh/internationalization_and_SEO.md)、[hreflang 多语言 SEO 指南](https://github.com/aymericzip/intlayer/blob/main/docs/blog/zh/hreflang_guide_multilingual_seo.md)以及 [Next.js 多语言 SEO 方案对比](https://github.com/aymericzip/intlayer/blob/main/docs/blog/zh/nextjs-multilingual-seo-comparison.md)。 ## 在 Next.js 应用中配置 Lingui 的分步指南 以下是我们将要构建的项目结构: ```bash . ├── lingui.config.ts ├── next.config.ts └── src ├── proxy.ts # 语言路由与检测 ├── locales │ ├── en │ │ └── messages.po # 由 `lingui extract` 生成 │ ├── fr │ │ └── messages.po │ └── es │ └── messages.po ├── i18n │ ├── config.ts # 语言配置与 URL 辅助工具 │ ├── appRouterI18n.ts # 仅限服务端的目录与实例 │ ├── initLingui.ts │ ├── negotiateLocale.ts │ └── metadata.ts # generateMetadata 构建器 ├── components │ ├── LinguiClientProvider.tsx │ ├── LocaleSwitcher.tsx │ └── LocalizedLink.tsx └── app ├── sitemap.ts ├── robots.ts └── [locale] ├── layout.tsx ├── page.tsx ├── not-found.tsx ├── [...rest] │ └── page.tsx # 未知路径的本地化 404 └── about └── page.tsx ``` ```bash packageManager="npm" npm install @lingui/core @lingui/react npm install -D @lingui/cli @lingui/swc-plugin @lingui/loader @lingui/format-po ``` ```bash packageManager="pnpm" pnpm add @lingui/core @lingui/react pnpm add -D @lingui/cli @lingui/swc-plugin @lingui/loader @lingui/format-po ``` ```bash packageManager="yarn" yarn add @lingui/core @lingui/react yarn add -D @lingui/cli @lingui/swc-plugin @lingui/loader @lingui/format-po ``` ```bash packageManager="bun" bun add @lingui/core @lingui/react bun add -D @lingui/cli @lingui/swc-plugin @lingui/loader @lingui/format-po ``` - **@lingui/core** / **@lingui/react**:运行时、`I18nProvider`、用于服务端组件的 `setI18n` 以及宏(`@lingui/core/macro`、`@lingui/react/macro`)。 - **@lingui/swc-plugin**:在 Next.js SWC 编译流水线中编译宏。 - **@lingui/loader**:在导入时编译 `.po` 目录,无需手动执行 `lingui compile`。 - **@lingui/cli**:通过 `lingui extract` 提取消息到语言目录中。 > `@lingui/swc-plugin` 是一个与 Next.js 的 SWC 版本绑定的 WebAssembly 插件。如果在升级 Next.js 后构建失败,请将插件更新到其 README 中注明的兼容版本。 使用单个文件统一定义语言和 URL 辅助函数。路由、元数据、站点地图以及 Lingui 均从中读取配置。 ```ts fileName="src/i18n/config.ts" export const locales = ["en", "fr", "es"] as const; export type Locale = (typeof locales)[number]; export const defaultLocale: Locale = "en"; /** 公共源站地址,用于规范 URL、hreflang 和站点地图。 */ export const siteUrl = "https://example.com"; /** 存储访问者显式选择的语言环境 Cookie。 */ export const localeCookieName = "NEXT_LOCALE"; /** Open Graph 期望使用 `language_TERRITORY` 格式代码。 */ export const openGraphLocales: Record = { en: "en_US", fr: "fr_FR", es: "es_ES", }; export const isLocale = (value: unknown): value is Locale => typeof value === "string" && (locales as readonly string[]).includes(value); export const resolveLocale = (value: string | undefined): Locale => isLocale(value) ? value : defaultLocale; const rightToLeftLanguages = new Set(["ar", "fa", "he", "ur", "ps", "yi"]); export const getTextDirection = (locale: string): "ltr" | "rtl" => rightToLeftLanguages.has(new Intl.Locale(locale).language) ? "rtl" : "ltr"; /** `localizePath("/about", "fr")` → `/fr/about`,默认语言不带前缀。 */ export const localizePath = (path: string, locale: Locale): string => { if (locale === defaultLocale) return path; return path === "/" ? `/${locale}` : `/${locale}${path}`; }; /** `/fr/about` → `/about` */ export const stripLocale = (pathname: string): string => { const [, firstSegment, ...rest] = pathname.split("/"); return isLocale(firstSegment) ? `/${rest.join("/")}` : pathname; }; export const getAbsoluteUrl = (path: string, locale: Locale): string => `${siteUrl}${localizePath(path, locale)}`; export const getLocaleName = (locale: Locale): string => new Intl.DisplayNames([locale], { type: "language" }).of(locale) ?? locale; ``` ```ts fileName="lingui.config.ts" import { defineConfig } from "@lingui/cli"; import { formatter } from "@lingui/format-po"; import { defaultLocale, locales } from "./src/i18n/config"; export default defineConfig({ sourceLocale: defaultLocale, locales: [...locales], catalogs: [ { path: "/src/locales/{locale}/messages", include: ["src"], }, ], format: formatter({ lineNumbers: false }), }); ``` SWC 插件用于编译宏,loader 用于编译 `.po` 文件,同时支持 Turbopack(Next.js 16 默认)与 webpack: ```ts fileName="next.config.ts" import type { NextConfig } from "next"; const nextConfig: NextConfig = { experimental: { swcPlugins: [["@lingui/swc-plugin", {}]], }, turbopack: { rules: { "*.po": { loaders: ["@lingui/loader"], as: "*.js" }, }, }, webpack: (config) => { config.module.rules.push({ test: /\.po$/, use: "@lingui/loader" }); return config; }, }; export default nextConfig; ``` 添加提取脚本: ```json fileName="package.json" { "scripts": { "i18n:extract": "lingui extract --clean", "i18n:check": "lingui extract --clean && git diff --exit-code src/locales" } } ``` 服务端组件没有 React context,因此 Lingui 提供了 `setI18n` 来为当前渲染注册实例。该模块在**每个服务端进程中仅加载一次**所有目录,并为每个语言环境创建一个 `I18n` 实例。它是 `server-only` 的:其他语言的目录绝不会进入客户端打包产物中。 ```ts fileName="src/i18n/appRouterI18n.ts" import "server-only"; import { type I18n, type Messages, setupI18n } from "@lingui/core"; import { type Locale, locales } from "./config"; const loadCatalog = async (locale: Locale): Promise<[Locale, Messages]> => { const { messages } = await import(`../locales/${locale}/messages.po`); return [locale, messages]; }; const catalogs = Object.fromEntries( await Promise.all(locales.map(loadCatalog)) ) as Record; const i18nInstances = Object.fromEntries( locales.map((locale) => [ locale, setupI18n({ locale, messages: { [locale]: catalogs[locale] } }), ]) ) as Record; export const getMessages = (locale: Locale): Messages => catalogs[locale]; export const getI18nInstance = (locale: Locale): I18n => i18nInstances[locale]; ``` ```ts fileName="src/i18n/initLingui.ts" import { setI18n } from "@lingui/react/server"; import { getI18nInstance } from "./appRouterI18n"; import type { Locale } from "./config"; /** * 为当前服务端组件渲染注册实例。 * 需在每个 layout 和 page 中调用。 */ export const initLingui = (locale: Locale) => { const i18n = getI18nInstance(locale); setI18n(i18n); return i18n; }; ``` 为了让 TypeScript 支持 `.po` 导入,声明一次模块类型: ```ts fileName="src/i18n/po.d.ts" declare module "*.po" { import type { Messages } from "@lingui/core"; export const messages: Messages; } ``` 客户端组件从 React context 中读取翻译。Provider 从服务端布局接收当前活跃语言的目录,并初始化创建一次自身的实例。 ```tsx fileName="src/components/LinguiClientProvider.tsx" "use client"; import { type Messages, setupI18n } from "@lingui/core"; import { I18nProvider } from "@lingui/react"; import { type ReactNode, useState } from "react"; type LinguiClientProviderProps = { children: ReactNode; initialLocale: string; initialMessages: Messages; }; export const LinguiClientProvider = ({ children, initialLocale, initialMessages, }: LinguiClientProviderProps) => { const [i18n] = useState(() => setupI18n({ locale: initialLocale, messages: { [initialLocale]: initialMessages }, }) ); return {children}; }; ``` `[locale]` 路径段包含根布局。`generateStaticParams` 在构建时预渲染每种语言,而 `dynamicParams = false` 会对任何其他前缀返回 404。 ```tsx fileName="src/app/[locale]/layout.tsx" import type { Metadata } from "next"; import { notFound } from "next/navigation"; import { LinguiClientProvider } from "@/components/LinguiClientProvider"; import { LocaleSwitcher } from "@/components/LocaleSwitcher"; import { getMessages } from "@/i18n/appRouterI18n"; import { getTextDirection, isLocale, locales, siteUrl } from "@/i18n/config"; import { initLingui } from "@/i18n/initLingui"; export const generateStaticParams = () => locales.map((locale) => ({ locale })); // 未知前缀 (/xx/about) → 404 export const dynamicParams = false; export const metadata: Metadata = { // 解析相对 canonical 与 Open Graph URL metadataBase: new URL(siteUrl), }; const LocaleLayout = async ({ children, params }: LayoutProps<"/[locale]">) => { const { locale } = await params; if (!isLocale(locale)) notFound(); initLingui(locale); return (
{children}
); }; export default LocaleLayout; ``` > 客户端 provider 会接收当前活跃语言的完整目录。这就是基准测试中所衡量的“其他页面泄露”。将文本保留在服务端组件中可以限制客户端实际所需的内容。对于大型应用,Lingui 的实验性按页面提取器(`lingui.config.ts` 中的 `experimental.extractor`)可以按入口点拆分消息目录。
服务端组件使用与客户端组件相同的宏。由于在同一布局下的页面间导航时布局不会重新渲染,因此在页面中也必须调用 `initLingui`。 ```tsx fileName="src/app/[locale]/about/page.tsx" import { Trans, useLingui } from "@lingui/react/macro"; import { Counter } from "@/components/Counter"; import { resolveLocale } from "@/i18n/config"; import { initLingui } from "@/i18n/initLingui"; const AboutPage = async ({ params }: PageProps<"/[locale]/about">) => { const { locale } = await params; initLingui(resolveLocale(locale)); return ; }; const AboutContent = () => { const { t } = useLingui(); return (

About us

We build fast, multilingual applications.

); }; export default AboutPage; ```
客户端组件使用相同的导入方式。宏会直接从 `LinguiClientProvider` 中读取实例。 ```tsx fileName="src/components/Counter.tsx" "use client"; import { Plural, Trans, useLingui } from "@lingui/react/macro"; import { useState } from "react"; export const Counter = () => { const { t, i18n } = useLingui(); const [count, setCount] = useState(0); return (

{i18n.number(count)}

); }; ```
运行提取命令。Lingui 会将 `src` 中找到的每条消息写入各个语言目录: ```bash npm run i18n:extract ``` 然后翻译每个条目的 `msgstr`: ```plaintext fileName="src/locales/fr/messages.po" msgid "About us" msgstr "À propos" msgid "We build <0>fast, multilingual applications." msgstr "Nous créons des applications <0>rapides et multilingues." msgid "Learn who we are and why we built this application." msgstr "Découvrez qui nous sommes et pourquoi nous avons créé cette application." msgid "{count, plural, =0 {No clicks yet} one {# click} other {# clicks}}" msgstr "{count, plural, =0 {Aucun clic} one {# clic} other {# clics}}" ``` ```plaintext fileName="src/locales/es/messages.po" msgid "About us" msgstr "Sobre nosotros" msgid "We build <0>fast, multilingual applications." msgstr "Creamos aplicaciones <0>rápidas y multilingües." msgid "Learn who we are and why we built this application." msgstr "Descubre quiénes somos y por qué creamos esta aplicación." msgid "{count, plural, =0 {No clicks yet} one {# click} other {# clicks}}" msgstr "{count, plural, =0 {Ningún clic} one {# clic} other {# clics}}" ``` > `<0>` 占位符保留了 `` 中的 JSX 元素位置,使翻译人员可以在不改动代码结构的情况下调整它们的位置。 Next.js 16 将 `middleware.ts` 重命名为 `proxy.ts`。Proxy 实现了“按需添加前缀”策略: - `/fr/about` 按原样响应; - `/en/about` 重定向至 `/about`,确保默认语言拥有唯一的 URL; - `/about` 在内部重写为 `/en/about`,URL 保持不变; - 首次访问 `/` 时重定向至首选语言(先检测 Cookie,再检测 `Accept-Language`)。 ```ts fileName="src/i18n/negotiateLocale.ts" import { isLocale, type Locale } from "./config"; /** "fr-CA,fr;q=0.9,en;q=0.8" → "fr" */ export const negotiateLocale = ( acceptLanguage: string | null | undefined ): Locale | undefined => { if (!acceptLanguage) return undefined; return acceptLanguage .split(",") .map((part) => { const [tag = "", quality] = part.trim().split(";q="); return { language: tag.toLowerCase().split("-")[0], quality: quality ? Number(quality) : 1, }; }) .sort((first, second) => second.quality - first.quality) .map(({ language }) => language) .find(isLocale); }; ``` ```ts fileName="src/proxy.ts" import { type NextRequest, NextResponse } from "next/server"; import { defaultLocale, isLocale, localeCookieName, localizePath, stripLocale, } from "@/i18n/config"; import { negotiateLocale } from "@/i18n/negotiateLocale"; export const proxy = (request: NextRequest) => { const { pathname } = request.nextUrl; const firstSegment = pathname.split("/")[1]; const url = request.nextUrl.clone(); if (isLocale(firstSegment)) { // /en/about → /about: 默认语言使用唯一 URL if (firstSegment === defaultLocale) { url.pathname = stripLocale(pathname); return NextResponse.redirect(url, 308); } return NextResponse.next(); } // 首次访问 "/": 将访问者重定向至其对应语言 if (pathname === "/") { const cookieLocale = request.cookies.get(localeCookieName)?.value; const preferredLocale = isLocale(cookieLocale) ? cookieLocale : negotiateLocale(request.headers.get("accept-language")); if (preferredLocale && preferredLocale !== defaultLocale) { url.pathname = localizePath("/", preferredLocale); return NextResponse.redirect(url, 307); } } // /about → 由 /en/about 提供服务,URL 保持不变 url.pathname = `/${defaultLocale}${pathname === "/" ? "" : pathname}`; return NextResponse.rewrite(url); }; export const config = { // 排除 API 路由、Next.js 内部文件和静态资源(sitemap.xml、robots.txt 等) matcher: ["/((?!api|_next|.*\\..*).*)"], }; ``` `usePathname` 返回浏览器当前看到的 URL(如 `/about` 或 `/fr/about`)。去除语言前缀后,构建每种语言对应的链接。切换器渲染真实的链接标签,以便搜索引擎爬虫发现所有语言版本,同时 Cookie 会持久化保存用户的显式选择。 ```tsx fileName="src/components/LocaleSwitcher.tsx" "use client"; import { useLingui } from "@lingui/react/macro"; import Link from "next/link"; import { usePathname } from "next/navigation"; import { getLocaleName, type Locale, localeCookieName, locales, localizePath, stripLocale, } from "@/i18n/config"; const persistLocale = (locale: Locale) => { document.cookie = `${localeCookieName}=${locale}; Path=/; Max-Age=31536000; SameSite=Lax`; }; export const LocaleSwitcher = () => { const { i18n, t } = useLingui(); const basePath = stripLocale(usePathname()); return ( ); }; ``` ```tsx fileName="src/components/LocalizedLink.tsx" "use client"; import { useLingui } from "@lingui/react"; import Link from "next/link"; import type { ComponentProps } from "react"; import { type Locale, localizePath } from "@/i18n/config"; type LocalizedLinkProps = Omit, "href"> & { /** 不带语言前缀的路径,例如 "/about" */ href: string; }; export const LocalizedLink = ({ href, ...props }: LocalizedLinkProps) => { const { i18n } = useLingui(); return ; }; ``` 该组件在服务端组件中也能正常工作,因为它是在 `LinguiClientProvider` 内部渲染的: ```tsx About us ``` 只要每个页面提供以下信息,各语言版本都能独立获得搜索引擎排名: - **已翻译**的 `title` 和 `description`; - 指向自身的 **canonical** 规范链接; - **每个语言环境一个 `hreflang` 备用链接**,外加 **`x-default`**; - **Open Graph** 的 `locale`、`alternateLocale` 和 `url`; - 带有 `inLanguage` 的 **JSON-LD** 数据。 `generateMetadata` 在 React 组件树之外执行,因此它使用 `msg` 宏直接操作服务端实例: ```ts fileName="src/i18n/metadata.ts" import type { Metadata } from "next"; import { defaultLocale, getAbsoluteUrl, type Locale, locales, openGraphLocales, } from "./config"; type LocalizedMetadataOptions = { /** 不带语言前缀的路径,例如 "/about" */ path: string; locale: Locale; title: string; description: string; }; export const buildLocalizedMetadata = ({ path, locale, title, description, }: LocalizedMetadataOptions): Metadata => { const url = getAbsoluteUrl(path, locale); return { title, description, alternates: { canonical: url, languages: { ...Object.fromEntries( locales.map((alternateLocale) => [ alternateLocale, getAbsoluteUrl(path, alternateLocale), ]) ), "x-default": getAbsoluteUrl(path, defaultLocale), }, }, openGraph: { type: "website", title, description, url, locale: openGraphLocales[locale], alternateLocale: locales .filter((alternateLocale) => alternateLocale !== locale) .map((alternateLocale) => openGraphLocales[alternateLocale]), }, }; }; ``` ```tsx fileName="src/app/[locale]/about/page.tsx" import { msg } from "@lingui/core/macro"; import type { Metadata } from "next"; import { getI18nInstance } from "@/i18n/appRouterI18n"; import { resolveLocale } from "@/i18n/config"; import { buildLocalizedMetadata } from "@/i18n/metadata"; export const generateMetadata = async ({ params, }: PageProps<"/[locale]/about">): Promise => { const locale = resolveLocale((await params).locale); const i18n = getI18nInstance(locale); return buildLocalizedMetadata({ path: "/about", locale, title: i18n._(msg`About us`), description: i18n._( msg`Learn who we are and why we built this application.` ), }); }; // ... 第 7 步中的页面组件 ``` JSON-LD 由页面本身渲染。页面文件只能导出 Next.js 约定的字段,因此请将该组件保存在独立文件中: ```tsx fileName="src/components/WebPageJsonLd.tsx" import { getAbsoluteUrl, type Locale } from "@/i18n/config"; type WebPageJsonLdProps = { path: string; locale: Locale; title: string; }; export const WebPageJsonLd = ({ path, locale, title }: WebPageJsonLdProps) => (