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

ItemLanguage or codeNote
Titles, copy, labelsDictionaryOne JSON per language, loaded on the server
Metadata and hreflangCodegenerateMetadata with alternates.languages
Date and number formattingCodeIntl.DateTimeFormat with the current locale
Page slugsCodeOne 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.