--- createdAt: 2026-09-26 updatedAt: 2026-09-26 priority: 9 title: "Next.js 16 i18n with Lingui: App Router Setup Guide" description: "Set up Lingui in the Next.js 16 App Router: Server Components, SWC macros, proxy routing, metadata, hreflang, sitemap and robots.txt, with benchmark data." keywords: - Lingui - LinguiJS - Next.js - Next.js 16 - App Router - React Server Components - Internationalization - i18n - SEO - Blog slugs: - blog - nextjs-internationalization-using-lingui history: - version: 9.5.10 date: 2026-09-26 changes: "Initial version" author: aymericzip --- # How to internationalize your Next.js application using Lingui in 2026 ## Table of Contents ## What is Lingui? **Lingui** is an i18n library built around **macros** and **message extraction**. You write the source text in your components (`` t`Hello` ``, `Hello`), `lingui extract` collects every message into catalogs (PO files by default), and a loader compiles them to compact JavaScript. Messages use ICU MessageFormat, and Lingui supports **React Server Components** in the App Router. This guide sets up Lingui in a **Next.js 16 App Router** project, with: - **Macros compiled by SWC**, so Turbopack keeps its speed. - **Server and Client Components** sharing the same `Trans` and `useLingui` API. - **Locale routing** through `proxy.ts`: `/about` for the default locale, `/fr/about` for the others, and first-visit language detection. - **Static rendering** of every locale with `generateStaticParams`. - **Complete multilingual SEO**: translated `generateMetadata`, canonical, `hreflang` with `x-default`, Open Graph locales, JSON-LD, `sitemap.ts`, `robots.ts` and localized 404 pages. > Looking for another library? See the [next-intl guide](https://github.com/aymericzip/intlayer/blob/main/docs/blog/en/i18n_using_next-intl.md), the [next-i18next guide](https://github.com/aymericzip/intlayer/blob/main/docs/blog/en/i18n_using_next-i18next.md), or the [Next.js + Intlayer guide](https://github.com/aymericzip/intlayer/blob/main/docs/docs/en/intlayer_with_nextjs_16.md). > Using TanStack Start? See the [TanStack Start + Lingui guide](https://github.com/aymericzip/intlayer/blob/main/docs/blog/en/i18n_using_tanstack-start_lingui.md). Comparing libraries? Read [Lingui vs Intlayer](https://github.com/aymericzip/intlayer/blob/main/docs/blog/en/lingui_vs_intlayer.md) and [next-i18next vs next-intl vs Intlayer](https://github.com/aymericzip/intlayer/blob/main/docs/blog/en/next-i18next_vs_next-intl_vs_intlayer.md). ## What the benchmark says about Lingui on Next.js The [i18n benchmark](https://github.com/aymericzip/intlayer/blob/main/docs/docs/en/benchmark/nextjs.md) runs the same 10-page, 10-locale Next.js app with every major library and measures what the browser actually downloads. Key figures for `@lingui/core@6.6.0` on Next.js 16, measured on 2026-09-26 (gzip): | Setup | Library size | JS per page | Other-locale leak | Other-page leak | | :-------------------------------- | -----------: | ----------: | ----------------: | --------------: | | No i18n (base app) | - | 141.0 KB | 0% | 0% | | Lingui (setup of this guide) | 72.1 KB | 145.4 KB | 2.8% | 89.9% | | `@intlayer/lingui` (compat) | 10.7 KB | 221.6 KB | 50% | 90% | | `next-intlayer` (native Intlayer) | 4.9 KB | 141.5 KB | 0% | 0% | What to take away: - **One catalog per locale still leaks other pages' messages** to the client provider. Keep as much text as possible in Server Components, which send rendered HTML, not catalogs. - **The Lingui runtime weighs ~72 KB gzip.** The `@intlayer/lingui` compat adapter cuts the runtime to ~11 KB, but in this benchmark the Next.js compat setup still ships whole catalogs to the page. The native `next-intlayer` API is the setup that stays at the base app size. > See the full data: [Next.js benchmark report](https://github.com/aymericzip/intlayer/blob/main/docs/docs/en/benchmark/nextjs.md), and the [benchmark repository](https://github.com/intlayer-org/benchmark-i18n). ## Feature comparison on Next.js How Lingui compares with `next-intl` and Intlayer on the features a Next.js App Router project usually needs: | Feature | `next-intlayer` (Intlayer) | Lingui | `next-intl` | | ----------------------------------- | -------------------------------------------------- | ------------------------------------------------------------- | ------------------------------------------ | | **Translations near components** | ✅ Content co-located with each component | ⚠️ Source text in components, catalogs centralized | ❌ Centralized JSON | | **TypeScript integration** | ✅ Auto-generated strict types | ⚠️ Macros typed, message catalogs not | ✅ Good, via `AppConfig` augmentation | | **Missing translation detection** | ✅ TypeScript errors and build-time warnings | ⚠️ Runtime fallback to the source text | ⚠️ Runtime fallback | | **Rich content (JSX, Markdown)** | ✅ Direct support | ✅ JSX inside ``, no Markdown | ⚠️ Tags via `t.rich`, no Markdown | | **AI translation** | ✅ Your own provider and API key, with app context | ❌ No | ❌ No | | **Visual editor / CMS** | ✅ Local visual editor + optional CMS | ❌ Via external platforms | ❌ Via external platforms | | **Localized routing** | ✅ Built-in | ❌ Write your own `proxy.ts` | ✅ Built-in `[locale]` segment | | **Pluralization** | ✅ Enumeration-based | ✅ ICU, `` macro | ✅ ICU | | **Content formats** | ✅ `.ts`, `.tsx`, `.js`, `.json`, `.md`, `.yaml` | ✅ PO, JSON, CSV | ✅ `.json`, `.js`, `.ts` | | **ICU MessageFormat** | ✅ Via `format: "icu"` | ✅ Native | ✅ Native | | **SEO helpers (hreflang, sitemap)** | ✅ Metadata, sitemap and robots.txt helpers | ❌ Manual | ✅ Good | | **Server Components** | ✅ Direct access in any Server Component | ⚠️ `setI18n` in every layout and page | ⚠️ `await getTranslations()` per component | | **Per-component tree-shaking** | ✅ At build time (Babel / SWC) | ⚠️ One catalog per locale, per-page extractor is experimental | ⚠️ Manual, with `pick()` per route | | **Runtime size (gzip, benchmark)** | 4.9 KB | 72.1 KB | 14.7 KB | | **Missing translations in CI** | ✅ `npx intlayer test` | ✅ `lingui compile --strict` | ⚠️ Not built-in | | **Ecosystem / community** | ⚠️ Smaller, growing fast | ✅ Mature | ✅ Large | > Runtime sizes come from the [Next.js benchmark](https://github.com/aymericzip/intlayer/blob/main/docs/docs/en/benchmark/nextjs.md). For a detailed discussion, read [Lingui vs Intlayer](https://github.com/aymericzip/intlayer/blob/main/docs/blog/en/lingui_vs_intlayer.md). > Other Next.js guides: [next-intl](https://github.com/aymericzip/intlayer/blob/main/docs/blog/en/i18n_using_next-intl.md), [next-i18next](https://github.com/aymericzip/intlayer/blob/main/docs/blog/en/i18n_using_next-i18next.md), and [Intlayer](https://github.com/aymericzip/intlayer/blob/main/docs/docs/en/intlayer_with_nextjs_16.md). ## Practices you should follow - **Set `lang` and `dir` on ``** in the `[locale]` layout. - **Prefer Server Components** for text: they render HTML on the server and don't need the catalog on the client. - **Call `initLingui(locale)` in every layout and page.** Layouts don't re-render on navigation, so a page cannot rely on its layout having set the locale. - **Keep one URL per locale** and pre-render every locale with `generateStaticParams`. - **Translate your metadata** in `generateMetadata`, with `canonical`, `hreflang` and `x-default`. - **Generate a multilingual sitemap and robots.txt** with the `sitemap.ts` and `robots.ts` conventions. - **Use real links for the locale switcher**, so crawlers discover every language. - **Run `lingui extract` in CI** so a new message never ships untranslated. > See our guide on [internationalization and SEO](https://github.com/aymericzip/intlayer/blob/main/docs/blog/en/internationalization_and_SEO.md), the [hreflang guide](https://github.com/aymericzip/intlayer/blob/main/docs/blog/en/hreflang_guide_multilingual_seo.md) and the [Next.js multilingual SEO comparison](https://github.com/aymericzip/intlayer/blob/main/docs/blog/en/nextjs-multilingual-seo-comparison.md). ## Step-by-Step Guide to Set Up Lingui in a Next.js Application Here's the project structure we'll be creating: ```bash . ├── lingui.config.ts ├── next.config.ts └── src ├── proxy.ts # Locale routing and detection ├── locales │ ├── en │ │ └── messages.po # Generated by `lingui extract` │ ├── fr │ │ └── messages.po │ └── es │ └── messages.po ├── i18n │ ├── config.ts # Locales, URL helpers │ ├── appRouterI18n.ts # Server-only catalogs and instances │ ├── initLingui.ts │ ├── negotiateLocale.ts │ └── metadata.ts # generateMetadata builder ├── components │ ├── LinguiClientProvider.tsx │ ├── LocaleSwitcher.tsx │ └── LocalizedLink.tsx └── app ├── sitemap.ts ├── robots.ts └── [locale] ├── layout.tsx ├── page.tsx ├── not-found.tsx ├── [...rest] │ └── page.tsx # Localized 404 for unknown paths └── 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**: runtime, `I18nProvider`, `setI18n` for Server Components, and the macros (`@lingui/core/macro`, `@lingui/react/macro`). - **@lingui/swc-plugin**: compiles the macros inside the Next.js SWC pipeline. - **@lingui/loader**: compiles `.po` catalogs on import, so `lingui compile` is not needed. - **@lingui/cli**: `lingui extract` to collect messages into catalogs. > `@lingui/swc-plugin` is a WebAssembly plugin tied to the SWC version of Next.js. If the build fails after a Next.js upgrade, update the plugin to the version listed as compatible in its README. A single file defines locales and URL helpers. Routing, metadata, sitemap and Lingui all read from it. ```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"; /** Public origin, used for canonical URLs, hreflang and the sitemap. */ export const siteUrl = "https://example.com"; /** Cookie storing the locale explicitly chosen by the visitor. */ export const localeCookieName = "NEXT_LOCALE"; /** Open Graph expects `language_TERRITORY` codes. */ 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`, default locale unprefixed. */ 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 }), }); ``` The SWC plugin compiles the macros, and the loader compiles `.po` files, for both Turbopack (default in Next.js 16) and 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; ``` Add the extraction scripts: ```json fileName="package.json" { "scripts": { "i18n:extract": "lingui extract --clean", "i18n:check": "lingui extract --clean && git diff --exit-code src/locales" } } ``` Server Components have no React context, so Lingui provides `setI18n` to register the instance for the current render. This module loads every catalog **once per server process** and creates one `I18n` instance per locale. It is `server-only`: catalogs of other locales never reach the client bundle. ```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"; /** * Registers the instance for the current Server Component render. * Call it in every layout and page. */ export const initLingui = (locale: Locale) => { const i18n = getI18nInstance(locale); setI18n(i18n); return i18n; }; ``` For TypeScript to accept the `.po` import, declare the module once: ```ts fileName="src/i18n/po.d.ts" declare module "*.po" { import type { Messages } from "@lingui/core"; export const messages: Messages; } ``` Client Components read translations from a React context. The provider receives the catalog of the active locale from the server layout, and creates its own instance once. ```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}; }; ``` The `[locale]` segment holds the root layout. `generateStaticParams` pre-renders every locale at build time, and `dynamicParams = false` returns a 404 for any other prefix. ```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 })); // Unknown prefixes (/xx/about) → 404 export const dynamicParams = false; export const metadata: Metadata = { // Resolves relative canonical and Open Graph URLs 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; ``` > The client provider receives the whole catalog of the active locale. This is what the benchmark measures as "other-page leak". Keeping text in Server Components limits what the client actually needs. For large apps, Lingui's experimental per-page extractor (`experimental.extractor` in `lingui.config.ts`) splits catalogs by entry point.
Server Components use the same macros as Client Components. `initLingui` must run in the page too, because a layout does not re-render when navigating between its pages. ```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; ```
Client Components use the same imports. The macros read the instance from `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)}

); }; ```
Run the extraction. Lingui writes every message found in `src` into each locale catalog: ```bash npm run i18n:extract ``` Then translate the `msgstr` of each entry: ```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>` placeholders keep the JSX elements of a `` in place, so translators can move them without touching the markup. Next.js 16 renamed `middleware.ts` to `proxy.ts`. The proxy implements the "as-needed" prefix strategy: - `/fr/about` is served as is; - `/en/about` redirects to `/about`, so the default locale has a single URL; - `/about` is rewritten internally to `/en/about`, without changing the URL; - a first visit on `/` redirects to the preferred language (cookie first, then `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: one URL for the default locale if (firstSegment === defaultLocale) { url.pathname = stripLocale(pathname); return NextResponse.redirect(url, 308); } return NextResponse.next(); } // First visit on "/": send the visitor to their language 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 → served by /en/about, URL unchanged url.pathname = `/${defaultLocale}${pathname === "/" ? "" : pathname}`; return NextResponse.rewrite(url); }; export const config = { // Skip API routes, Next.js internals and files (sitemap.xml, robots.txt...) matcher: ["/((?!api|_next|.*\\..*).*)"], }; ``` `usePathname` returns the URL seen by the browser (`/about` or `/fr/about`). Strip the locale, then build the link of each language. The switcher renders real links, so crawlers can reach every language version, and the cookie remembers the explicit choice. ```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"> & { /** Path without locale prefix, e.g. "/about" */ href: string; }; export const LocalizedLink = ({ href, ...props }: LocalizedLinkProps) => { const { i18n } = useLingui(); return ; }; ``` It works from Server Components too, because it renders inside `LinguiClientProvider`: ```tsx About us ``` Each language version can rank on its own, provided every page exposes: - a **translated** `title` and `description`; - a **canonical** URL pointing to itself; - one **`hreflang` alternate per locale**, plus **`x-default`**; - **Open Graph** `locale`, `alternateLocale` and `url`; - **JSON-LD** with `inLanguage`. `generateMetadata` runs outside the React tree, so it uses the server instance directly with the `msg` macro: ```ts fileName="src/i18n/metadata.ts" import type { Metadata } from "next"; import { defaultLocale, getAbsoluteUrl, type Locale, locales, openGraphLocales, } from "./config"; type LocalizedMetadataOptions = { /** Path without locale prefix, e.g. "/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.` ), }); }; // ... page component from step 7 ``` JSON-LD is rendered by the page itself. Page files may only export Next.js fields, so keep the component in its own file: ```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) => (