The App Router has no i18n block in next.config.ts; it gives you primitives instead: a dynamic segment for the locale, the proxy for detection, and Server Components for loading translations without shipping every language to the browser. After this lesson you will be able to structure a multilingual app under /[lang], detect the preferred language, load dictionaries on the server, and emit the metadata search engines expect.
Put the whole application under a [lang] segment so every URL carries its locale:
app/
└── [lang]/
├── layout.tsx # sets <html lang>
├── page.tsx # /en, /de, /hi
└── about/page.tsx # /en/about ...// app/[lang]/layout.tsx
const locales = ["en", "de", "hi"] as const;
export function generateStaticParams() {
return locales.map((lang) => ({ lang }));
}
export const dynamicParams = false; // unknown locales return 404
export default async function RootLayout({
children,
params,
}: {
children: React.ReactNode;
params: Promise<{ lang: string }>;
}) {
const { lang } = await params;
return (
<html lang={lang}>
<body>{children}</body>
</html>
);
}generateStaticParams pre-renders every locale and dynamicParams = false returns 404 for unknown ones. Links must include the locale prefix.
Visitors landing on / need to be sent to the right prefix. The proxy in the sample at the top of this lesson reads the Accept-Language header, ranks the languages with negotiator, and picks the best supported one with @formatjs/intl-localematcher (install both, plus @types/negotiator). When a user switches languages, store the choice in a cookie and check it before the header so the explicit preference wins.
Keep one JSON file per locale and load only the one you need inside a server-only helper:
// lib/dictionaries.ts
import "server-only";
const dictionaries = {
en: () => import("@/dictionaries/en.json").then((m) => m.default),
de: () => import("@/dictionaries/de.json").then((m) => m.default),
hi: () => import("@/dictionaries/hi.json").then((m) => m.default),
};
export type Locale = keyof typeof dictionaries;
export const getDictionary = (locale: Locale) => dictionaries[locale]();In app/[lang]/page.tsx, await params, call getDictionary(lang) and render t.home.title. Because the page is a Server Component, the dictionary never enters the client bundle; pass individual strings as props to Client Components that need them.
Dates, numbers and currencies should follow the locale too. The built-in Intl APIs need no library: new Intl.NumberFormat(lang, { style: "currency", currency: "EUR" }).format(price) and new Intl.DateTimeFormat(lang, { dateStyle: "long" }).format(date).
Tell search engines about translated versions with alternates.languages in metadata:
export async function generateMetadata({ params }: { params: Promise<{ lang: string }> }) {
const { lang } = await params;
return {
alternates: {
canonical: `/${lang}`,
languages: { en: "/en", de: "/de", hi: "/hi" },
},
};
}With metadataBase set, the hreflang links are absolute.
Hand-rolled dictionaries suit a few languages and simple strings. Libraries such as next-intl add pluralisation, interpolation, typed keys and a Link that inserts the locale automatically, on top of the same [lang] and proxy structure shown here.
Link hrefs, so navigation drops back to the default language._next and static files, which redirects asset requests.How does the App Router express the current locale in a URL?
app/[lang], pre-render locales with generateStaticParams, and set <html lang>.negotiator and intl-localematcher, honouring a cookie first.server-only helper so translations never bloat the client bundle.Intl.NumberFormat and Intl.DateTimeFormat for locale-aware formatting.alternates.languages for hreflang links; adopt next-intl for plurals and typed messages.Next lesson: Testing with Vitest and Playwright — unit-test components and run end-to-end tests against the real app.