Skip to content
maksim zaytsev
Заметки

12 августа 2026 · 7 мин

Самодельная 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 на каждой странице, молча перезаписывающие друг друга.

Три бага

  1. Metadata-маршруты уезжали в 404. app/icon.tsx и app/apple-icon.tsx генерируют URL без расширения вроде /icon/abc123. Matcher middleware видел путь без префикса локали, добавлял его — а иконки там не было. Решение — явно исключить эти маршруты из matcher'а; привычный паттерн «пропускать всё с точкой» их не покрывает, потому что расширения нет.
  2. Смена языка сбрасывала тёмную тему. Смена сегмента [locale] перерисовывает корневой layout вместе с <html>, и класс dark, который переключатель темы поставил на клиенте, исчезал. Маленький компонент ThemeSync заново применяет сохранённую тему в useLayoutEffect, привязанном к pathname, до первой отрисовки.
  3. Шрифты без нужных подмножеств. Кириллице и чешской диакритике нужны явные подмножества cyrillic и latin-ext в next/font, а генератору Open Graph-картинок — статические TTF с этими глифами, потому что satori не читает вариативные и woff2-шрифты.

Сделал бы так снова?

Для этого сайта — да. Весь механизм — около 150 строк, и каждую его часть можно прочитать за один присест. В тот момент, когда мне понадобятся правила плюрализации, локаль пользователя на сервере или переводчики, которые не разработчики, я возьму библиотеку — и оставлю часть «словари как код», потому что именно она себя окупила.