Several languages: an overview
Internationalisation (i18n) is preparing a site to work in several languages. The App Router has no built-in language switching: you assemble it from familiar parts — a dynamic segment, translation files and the proxy (proxy.ts). Here is the general scheme.
The language in the address
The language becomes the first segment of the address: /ru/posts, /en/posts. For that, all pages move into an app/[lang] folder:
app/
└── [lang]/
├── layout.tsx
├── page.tsx /ru, /en
└── posts/
└── page.tsx /ru/posts, /en/postsNow every page receives lang in params.
Dictionaries
Translations are kept in JSON files, one per language:
{
"posts": "Posts",
"newPost": "New post"
}This is dictionaries/en.json; dictionaries/ru.json has the same keys with Russian text. The function that loads a dictionary:
// lib/dictionaries.ts
import 'server-only';
const dictionaries = {
ru: () => import('@/dictionaries/ru.json').then((module) => module.default),
en: () => import('@/dictionaries/en.json').then((module) => module.default),
};
export type Locale = keyof typeof dictionaries;
export async function getDictionary(locale: Locale) {
return dictionaries[locale]();
}The server-only package (installed with npm install server-only) guarantees that the file never reaches the browser.
A translation on a page
// app/[lang]/posts/page.tsx
import { getDictionary, type Locale } from '@/lib/dictionaries';
export default async function PostsPage({ params }: { params: Promise<{ lang: Locale }> }) {
const { lang } = await params;
const t = await getDictionary(lang);
return <h1>{t.posts}</h1>;
}/en/posts shows "Posts", and /ru/posts shows the Russian word. The lang attribute of the html tag in the app/[lang]/layout.tsx layout also comes from params.
Choosing the default language
An address without a language, such as /posts, is handled by the proxy: it reads the accept-language header, picks a suitable language and redirects, for example to /en/posts. If the browser's language is not supported, the default language is used.
Ready-made libraries
The manual scheme is fine for a couple of languages and simple strings. For plural forms ("1 post", "5 posts"), dates, numbers and a language switcher, the next-intl library is more convenient — it is built on the same principles but does a lot for you.
Common mistake
Forgetting to check the lang value. The address /de/posts also lands in the [lang] folder, and dictionaries['de'] turns out undefined — the page crashes. Check the language against the list of supported ones and call notFound() if it is not there.