Самодельная i18n в Next.js app router
Этот сайт отдаётся на английском, русском и чешском и не использует ни одной i18n-библиотеки. Это не принципиальная позиция против next-intl — просто сайту из четырёх страниц, где контент правится как код, не нужны ICU-сообщения, загрузчики в рантайме и дерево провайдеров. Вот что ему действительно нужно и что меня по дороге укусило.
Маршрутизация
Каждая страница живёт под сегментом [locale]: /en/about, /ru/about, /cs/about. Список локалей — const-массив, а тип Locale выводится из него, поэтому добавить чешский значило добавить одну строку и дать TypeScript показать каждое место, где нужен перевод.
export const locales = ["en", "ru", "cs"] as const;
export type Locale = (typeof locales)[number];
export const defaultLocale: Locale = "en";
Запрос без префикса редиректит middleware. Порядок предпочтений: cookie locale, которую ставит переключатель языка, затем заголовок Accept-Language, затем локаль по умолчанию. Редирект — 307 с Vary: Accept-Language, Cookie, чтобы CDN не закешировал английский редирект для русскоязычного посетителя.
const locale = getPreferredLocale(request);
const url = request.nextUrl.clone();
url.pathname = `/${locale}${pathname}`;
const res = NextResponse.redirect(url, 307);
res.headers.set("Vary", "Accept-Language, Cookie");
Контент
Переводы — это TypeScript-модули: content/en/about.ts, content/ru/about.ts и так далее. Каждый экспортирует обычный объект, а неосновные локали сверяются с формой английского через satisfies, поэтому пропущенный ключ — это ошибка типов, а не пустая строка в продакшене. Длинные тексты — описания проектов, заметки — это Markdown внутри этих объектов, отрисованный через react-markdown. CMS нет; изменение контента — это pull request.
SEO
Каждая страница отдаёт canonical URL, hreflang-ссылки на все три локали и x-default, указывающий на путь без префикса — тот самый, который разрешает middleware. Поскольку Next сливает метаданные поверхностно, всё это делает один helper, buildPageMetadata(), а не объекты alternates на каждой странице, молча перезаписывающие друг друга.
Три бага
- Metadata-маршруты уезжали в 404.
app/icon.tsxиapp/apple-icon.tsxгенерируют URL без расширения вроде/icon/abc123. Matcher middleware видел путь без префикса локали, добавлял его — а иконки там не было. Решение — явно исключить эти маршруты из matcher'а; привычный паттерн «пропускать всё с точкой» их не покрывает, потому что расширения нет. - Смена языка сбрасывала тёмную тему. Смена сегмента
[locale]перерисовывает корневой layout вместе с<html>, и классdark, который переключатель темы поставил на клиенте, исчезал. Маленький компонентThemeSyncзаново применяет сохранённую тему вuseLayoutEffect, привязанном к pathname, до первой отрисовки. - Шрифты без нужных подмножеств. Кириллице и чешской диакритике нужны явные подмножества
cyrillicиlatin-extвnext/font, а генератору Open Graph-картинок — статические TTF с этими глифами, потому что satori не читает вариативные и woff2-шрифты.
Сделал бы так снова?
Для этого сайта — да. Весь механизм — около 150 строк, и каждую его часть можно прочитать за один присест. В тот момент, когда мне понадобятся правила плюрализации, локаль пользователя на сервере или переводчики, которые не разработчики, я возьму библиотеку — и оставлю часть «словари как код», потому что именно она себя окупила.