React
i18n in the App Router: URLs, dictionaries and metadata
One [lang] segment, server-side dictionaries and metadata alternates: how to set up i18n in the App Router with no extra package and no duplicated routes.
Key points
- `app/[lang]` is the only folder you need: the rest is dictionaries and metadata.
- The locale comes from the segment, not from a cookie, so every page stays indexable.
- Static generation multiplies pages by language and the cost must be measured.
i18n in the App Router lives in a dynamic segment, not in a package: app/[lang] plus a dictionary loaded on the server. If the locale exists only in a cookie, that page cannot be indexed.
A segment, not a package
The locale is part of the URL. app/[lang]/page.tsx serves every language, and generateStaticParams tells Next which values to generate at build time. No wrapper, no global provider, no configuration file.
app/
[lang]/
layout.tsx
page.tsx
dizionari/
it.json
en.json
layout.tsx
middleware.ts
Load the dictionary on the server
A static JSON import inside the server component is the simplest and fastest shape: the file lands in the server bundle, never in the client one. Pass down only the strings an interactive component needs.
// app/[lang]/page.tsx
import { notFound } from "next/navigation";
const lingue = ["it", "en"] as const;
type Lingua = (typeof lingue)[number];
const dizionari: Record Promise }>> = {
it: () => import("../dizionari/it.json"),
en: () => import("../dizionari/en.json"),
};
export function generateStaticParams() {
return lingue.map((lang) => ({ lang }));
}
export default async function Page({ params }: { params: Promise }) {
const { lang } = await params;
if (!lingue.includes(lang as Lingua)) notFound();
const dizionario = (await dizionari[lang as Lingua]()).default;
return {dizionario.titolo};
}
Where translation ends and code begins
| Item | Language or code | Note |
|---|---|---|
| Titles, copy, labels | Dictionary | One JSON per language, loaded on the server |
| Metadata and hreflang | Code | generateMetadata with alternates.languages |
| Date and number formatting | Code | Intl.DateTimeFormat with the current locale |
| Page slugs | Code | One scheme, translated only when SEO needs it |
Metadata: the part people break
generateMetadata must declare alternates.canonical and alternates.languages for every published language, plus openGraph.locale. A canonical that always points at Italian undoes the work on the rest of the page. The recurring mistakes are collected in hreflang errors that cost traffic.
EneUI indexes 111,985 components from 1,472 libraries and 1,556 blocks: the number was measured on 1 November 2026 on ui.eneawork.it, not estimated. A language switcher changes with the design system, not with the locale.
Catalogue components that fit here
Three records to start from: the language selector dropdown, the navbar block family with room for the switch, and the header blocks for a bilingual masthead.
The middleware redirect pattern is the same one I use for authentication, described in Next.js middleware for authentication. The framework comparison still holds in Astro vs Next.js for documentation. Folder layout is in App Router project structure.
The limit to budget for
generateStaticParams multiplies pages by language. Four hundred articles in two languages means 800 HTML files per build, and every new language adds a factor. Measure the cost before opening a third market, not after.
Frequently asked questions
Do I need a library such as next-intl?
No. A <code>[lang]</code> segment and a JSON dictionary cover the normal case. A library adds plurals, interpolation and missing-key checks: adopt one when you actually need those.
Should the locale live in a cookie or in the URL?
In the URL. A cookie gives you one indexable version and a redirect crawlers do not follow. The cookie is useful only as a first-visit preference.
How do I translate metadata?
With <code>generateMetadata</code>, which reads the same dictionary and returns <code>title</code>, <code>description</code> and <code>alternates</code> for the current language. No translated copy inside the component.
Can I translate only part of the site?
Yes. Declare in <code>alternates.languages</code> only the languages that really exist. An hreflang pointing at an unpublished page is an error, not a signal.
How do I handle plurals?
With <code>Intl.PluralRules</code> for the current language, or a library that wraps it. Concatenating suffixes produces wrong text in nearly every language.