Internationalisation in the Next.js App Router
The App Router dropped the built-in i18n routing from the Pages Router. Here is how to structure locales, detect languages and load translations without bloating the bundle.
The Pages Router shipped i18n routing as a config option; the App Router deliberately does not. That surprises teams mid-migration, but the reasoning is sound: locale handling is now built from ordinary primitives — dynamic segments, middleware and layouts — which makes it more flexible and much easier to reason about. Here is the shape of a solid setup, assuming nothing beyond Next.js itself.
Put the locale in the route
Everything hangs off a top-level dynamic segment: app/[locale]/ contains your entire page tree, so /en/about and /de/about resolve to the same components with a different param. The layout validates the locale and sets the html lang attribute.
// app/[locale]/layout.tsx
const locales = ['en', 'de', 'fr'] as const;
export default async function LocaleLayout({ children, params }: Props) {
const { locale } = await params;
if (!locales.includes(locale as Locale)) notFound();
return (
<html lang={locale}>
<body>{children}</body>
</html>
);
}Detect and redirect in middleware
Middleware handles the visitor who lands on a bare path with no locale prefix. Read the Accept-Language header, match it against your supported locales, and redirect to the best fit — remembering an explicit choice in a cookie so a visitor who switched to German is not bounced back to English by their browser settings. Keep the matcher tight so static assets skip the check entirely.
Two details save later pain: exclude API routes and files with extensions from the matcher, and emit hreflang alternates in your metadata so search engines connect the language versions rather than treating them as duplicate content.
Load only the dictionary you need
Translations belong on the server. A dictionary loader with a dynamic import per locale means the German bundle never ships to English visitors, and Server Components can read translations with no client-side i18n runtime at all.
const dictionaries = {
en: () => import('./dictionaries/en.json').then((m) => m.default),
de: () => import('./dictionaries/de.json').then((m) => m.default),
fr: () => import('./dictionaries/fr.json').then((m) => m.default),
};
export const getDictionary = (locale: Locale) => dictionaries[locale]();Type the dictionary against your default locale so a missing key in a translation file is a compile-time error, not a blank string discovered by a customer.
Dates, numbers and currencies do not belong in dictionaries at all. The Intl APIs built into JavaScript handle locale-aware formatting on the server without adding a byte to the bundle — pass the locale from params into your formatting helpers and let the platform do the work.
Keep locales static
Internationalisation should not cost you static rendering. Return your locales from generateStaticParams and every language version of every page is prerendered at build time. Combine that with per-locale metadata — hreflang alternates especially — and search engines index each language cleanly instead of guessing.
Decide early whether you need translated slugs — /de/ueber-uns rather than /de/about — because they touch routing, the CMS and the sitemap at once. If the business wants them, model the slug per locale in the CMS and resolve it in generateStaticParams; bolting them on later is miserable.
Or reach for a library
If you need plural rules, rich-text interpolation, date and number formatting across many locales, a library such as next-intl earns its place — it follows exactly the structure above while adding the formatting layer. For two or three locales and mostly static copy, the hand-rolled version is genuinely fine and one less dependency to track. Choose based on how much formatting complexity you actually have, not on what the tutorial used.
The architecture is the easy half of i18n; the hard half is workflow — keeping translations current as features ship. Solve the workflow with the same seriousness as the code and the whole thing stays manageable. Ship the first locale pair properly and each additional language becomes routine.