Мультиязычный сайт на Next.js: next-intl, hreflang, RTL#
Три моих продакшен-сайта не имеют ничего общего в тематике — портфолио разработчика, калькулятор для бегунов и медицинский сервис о влиянии погоды на здоровье — но у них абсолютно одинаковая i18n-архитектура. dodecaidr.pro говорит на пяти языках (ru, en, es, ja, zh-CN), runcalculator.pro — тоже на пяти, а meteohealth.pro — на шести, включая арабский с RTL-раскладкой. Все три построены на Next.js App Router и next-intl, и все три прошли через одни и те же грабли: сломанный hreflang, забытые переводы в паритет-тестах и раскладку, которая разваливается на арабском.
Эта статья — не введение в next-intl «с нуля». Это разбор конкретных архитектурных решений, которые я использую во всех трёх проектах, с примерами кода из runcalculator.pro и meteohealth.pro, и список ошибок, которые я либо совершил сам, либо целенаправленно обошёл.
Три продакшена, одна и та же головная боль#
Когда сайт живёт на одном языке, i18n — это библиотека для строк перевода. Когда языков пять и больше, i18n — это архитектурное решение, которое затрагивает роутинг, SEO, тестирование контента и вёрстку. На всех трёх проектах я пришёл к одному и тому же набору паттернов:
localePrefix: 'always'— каждый URL держит префикс локали (/ru/...,/en/...), без «голого» пути для дефолтного языка. Это упрощает кэширование, логи и hreflang, но требует явногоx-default.- middleware/proxy, который решает локаль до рендера — next-intl вычисляет локаль из URL, cookie и
Accept-Language, ещё до того как страница начнёт собираться. - hreflang и
x-default, сгенерированные из одного источника — не руками на каждой странице, а функцией, которая знает список локалей. - Локализованный контент в параллельных папках —
content/<type>/<locale>/..., а не JSON-словарь на десятки тысяч строк. - Паритет-тест локалей — скрипт, который падает в CI, если для новой статьи забыли одну из пяти-шести версий.
- RTL-проверка вёрстки — актуально не для всех трёх проектов, но там, где есть арабский (meteohealth.pro), без неё не обойтись.
Дальше — как каждый из этих пунктов выглядит в коде.
Единый источник правды: конфигурация локалей#
Первая ошибка, которую я видел в чужих проектах (и один раз допустил сам) — список локалей, продублированный в middleware, в конфиге next-intl и в компоненте переключателя языка. Стоит добавить локаль — и она появляется в переключателе, но не работает в middleware, потому что там отдельный массив.
Решение — один файл, который импортируют все остальные:
// lib/i18n/config.ts
export const locales = ['ru', 'en', 'es', 'ja', 'zh-CN'] as const
export type Locale = (typeof locales)[number]
export const defaultLocale: Locale = 'ru'
export const i18nConfig = {
locales,
defaultLocale,
localePrefix: 'always' as const, // every URL keeps its locale prefix
}
export function isValidLocale(locale: string): locale is Locale {
return locales.includes(locale as Locale)
}
export function getValidLocale(locale: string | undefined): Locale {
if (!locale) return defaultLocale
return isValidLocale(locale) ? locale : defaultLocale
}Middleware, генератор sitemap, генератор hreflang и загрузчик MDX-контента импортируют locales и defaultLocale отсюда же. Добавление новой локали — это правка одного массива плюс создание файлов переводов и контента; ни одна логика роутинга не меняется руками.
next-intl v4: requestLocale и загрузка сообщений#
В next-intl v4 локаль в getRequestConfig приходит не синхронно, а через Promise — это изменение сломало часть кода при апгрейде с v3, если он не проверял тип. Актуальный паттерн:
// i18n.ts
import { notFound } from 'next/navigation'
import { getRequestConfig } from 'next-intl/server'
import { locales, defaultLocale, isValidLocale, getValidLocale, type Locale } from './lib/i18n/config'
export default getRequestConfig(async ({ requestLocale }) => {
// next-intl v4: locale arrives as a Promise
const requested = await requestLocale
if (requested && !isValidLocale(requested)) {
notFound()
}
const locale = getValidLocale(requested)
const [common, marketing] = await Promise.all([
import(`./locales/${locale}/common.json`),
import(`./locales/${locale}/marketing.json`),
])
return {
locale,
messages: {
common: common.default,
marketing: marketing.default,
},
timeZone: getTimeZone(locale),
}
})
function getTimeZone(locale: Locale): string {
const timeZones: Record<Locale, string> = {
ru: 'Europe/Moscow',
en: 'America/New_York',
es: 'Europe/Madrid',
ja: 'Asia/Tokyo',
'zh-CN': 'Asia/Shanghai',
}
return timeZones[locale] ?? 'UTC'
}Два момента, которые редко пишут в туториалах: во-первых, notFound() вызывается, только если локаль передана и невалидна — а не при её отсутствии, иначе сломается любой путь, где locale ещё не определена. Во-вторых, часовой пояс — не декоративная деталь: если его не выставить, Intl.DateTimeFormat и форматтеры дат next-intl используют серверный TZ, и дата публикации статьи на японской версии сайта может показывать вчерашний день.
proxy.ts вместо middleware.ts: что изменилось в Next.js 16#
Next.js 16 переименовал middleware.ts в proxy.ts — файл и экспортируемая функция теперь называются иначе, но контракт с next-intl не изменился: createMiddleware из next-intl/middleware по-прежнему оборачивается в один хендлер.
// proxy.ts — renamed from middleware.ts in Next.js 16
import createMiddleware from 'next-intl/middleware'
import { NextRequest } from 'next/server'
import { locales, defaultLocale } from './lib/i18n/config'
const intlMiddleware = createMiddleware({
locales,
defaultLocale,
localePrefix: 'always',
})
export default function proxy(request: NextRequest) {
return intlMiddleware(request)
}
export const config = {
// skip /api, /_next and static files with a dot in the path
matcher: ['/((?!api|_next|.*\\..*).*)'],
}На практике в этот же файл удобно добавить сквозное логирование запросов (метод, путь, статус, длительность) — оно не мешает i18n-редиректам, если вызывается после intlMiddleware(request), а не вместо него.
hreflang и x-default: где ломается 75% сайтов#
Есть статистика (Ahrefs), что около 75% сайтов с международными версиями допускают ошибки в hreflang: отсутствие обратной ссылки (страница A ссылается на B, но не наоборот), битые URL в тегах и — самая частая — canonical, который на всех языковых версиях указывает на английскую страницу. Последнее фактически говорит поисковику «игнорируй остальные локали», и именно так владельцы сайтов сами обнуляют собственную i18n-работу.
Чтобы не набирать hreflang руками на каждой странице, я генерирую его одной функцией и переиспользую и в generateMetadata, и в sitemap:
// Generates alternates for both metadata and sitemap entries
function generateAlternates(route: string) {
const alternates: Record<string, string> = {}
for (const locale of locales) {
alternates[locale] = route
? `${baseUrl}/${locale}/${route}`
: `${baseUrl}/${locale}`
}
// With localePrefix: 'always' there is no locale-neutral URL,
// so x-default has to point somewhere — the default locale is the pragmatic choice
alternates['x-default'] = route
? `${baseUrl}/${defaultLocale}/${route}`
: `${baseUrl}/${defaultLocale}`
return alternates
}
// app/[locale]/articles/[slug]/page.tsx
export async function generateMetadata({ params }): Promise<Metadata> {
const { locale, slug } = await params
return {
alternates: {
canonical: `${baseUrl}/${locale}/articles/${slug}`,
languages: generateAlternates(`articles/${slug}`),
},
}
}Тонкость, которую легко пропустить: x-default должен встречаться один раз и указывать на страницу для пользователей, чей язык не совпал ни с одной локалью — обычно это домашняя страница или страница выбора языка. При localePrefix: 'always' локале-нейтрального URL не существует в принципе, так что x-default указывает на дефолтную локаль как на прагматичный компромисс, а не на «правильный» ответ — это стоит явно задокументировать в кодовой базе, чтобы через полгода никто не «исправил» это на несуществующий корневой путь.
Каждая страница должна ссылаться сама на себя (self-referencing hreflang) — если этого нет, поисковик имеет право проигнорировать весь набор alternate-тегов на странице.
MDX-контент в параллельных папках + Zod + паритет-тест#
JSON-словарь с переводами интерфейса — нормальное решение для кнопок и лейблов, но не для контента: статей, описаний проектов, политик конфиденциальности. Держать длинный текст в JSON — значит терять форматирование, код-блоки и возможность просто открыть файл в редакторе и написать текст. Поэтому контент лежит в параллельных папках по локали:
content/articles/
├── ru/general/nextjs-i18n-next-intl.mdx
├── en/general/nextjs-i18n-next-intl.mdx
├── es/general/nextjs-i18n-next-intl.mdx
├── ja/general/nextjs-i18n-next-intl.mdx
└── zh-CN/general/nextjs-i18n-next-intl.mdxФронтматтер каждого файла проходит через Zod-схему при чтении: обязательные поля, допустимые значения категорий и даты в формате YYYY-MM-DD — если переводчик (или я сам, в 23:00) забудет поле или опечатается в категории, сборка упадёт с понятной ошибкой Zod, а не тихо покажет пустую карточку статьи в проде.
Отдельный скрипт-паритет-тест проверяет, что для каждого slug существует файл во всех локалях и что структура JSON-словарей (ключи, не значения) совпадает между языками. Без такого теста типичный сценарий выглядит так: статья вышла на русском, PR смержен, а через месяц выясняется, что японская версия сайта всё ещё показывает страницу 404 или, того хуже, старый заголовок из общего дефолта.
RTL — не только dir="rtl": уроки meteohealth.pro#
Из трёх проектов только meteohealth.pro говорит на арабском, и именно там всплыли RTL-баги, которых не бывает в ru/en/es/ja/zh-CN — ни один из них не зеркалит layout. dir="rtl" на <html> закрывает примерно 60% работы: текст выравнивается и течёт в правильную сторону. Остальные 40% — это иконки, которые должны развернуться зеркально (стрелка «вперёд» не может показывать назад), поля ввода, которые должны принимать арабские цифры и текст без переключения направления по символам, и вёрстка на margin-left/padding-right, которая в RTL просто оказывается не с той стороны.
// app/[locale]/layout.tsx
import { locales, type Locale } from '@/lib/i18n/config'
const RTL_LOCALES: Locale[] = ['ar'] // meteohealth.pro only
export default async function LocaleLayout({
children,
params,
}: {
children: React.ReactNode
params: Promise<{ locale: Locale }>
}) {
const { locale } = await params
const dir = RTL_LOCALES.includes(locale) ? 'rtl' : 'ltr'
return (
<html lang={locale} dir={dir}>
<body className="ms-0 pe-4 rtl:pe-0 rtl:ps-4">
{children}
</body>
</html>
)
}Практические выводы, которые применимы к любому проекту с RTL-локалью:
- Использовать логические CSS-свойства (
margin-inline-start,padding-inline-end, в Tailwind —ms-/ps-/me-/pe-) вместо физических (margin-left/padding-right) — тогда зеркалирование происходит само, без дублирования классов черезrtl:. - Для случаев, которые логические свойства не покрывают (иконки, конкретные трансформации), — модификатор
rtl:в Tailwind, напримерrtl:rotate-180для стрелок. - Смешанный контент — арабский текст с вкраплениями латиницы (бренды, email, код) — стоит оборачивать в
<bdi>, иначе порядок символов на границе направлений может визуально разъезжаться. - RTL нельзя тестировать «на глаз» один раз перед релизом. Скриншот-сравнение ключевых экранов (карточки, формы, навигация) в
ltr- иrtl-вариантах ловит регрессии, которые иначе всплывают только в отзывах пользователей. - Реальный арабский текст в тестовой среде — с первого дня, а не «добавим переводы потом». RTL-баги на псевдо-зеркалированной латинице и на настоящей арабской вязи — разные баги.
Сравнение трёх проектов и чек-лист#
| Проект | Локалей | RTL | Контент | Особенность |
|---|---|---|---|---|
| dodecaidr.pro | 5 (ru, en, es, ja, zh-CN) | нет | MDX + Zod-схемы, паритет-тест в CI | Портфолио и статьи — контент, а не UI-строки |
| runcalculator.pro | 5 | нет | MDX-описания и локализованные формулы расчёта | Числовые форматы (темп, дистанция) завязаны на locale, не только текст |
| meteohealth.pro | 6 (+ арабский) | да | MDX + локализованные скриншоты и политики на 5+ языках | Единственный проект, где RTL — не теория, а ежедневная проверка вёрстки |
Если коротко формулировать, что переносится из проекта в проект без изменений:
- Локали и дефолтная локаль — в одном файле, а не в трёх местах.
- Middleware/
proxy.tsопределяет локаль до рендера и ничего не решает по-своему поверх next-intl. - hreflang и
x-defaultгенерируются функцией, а не копируются вручную по страницам. - Контент — в MDX по локалям, с Zod-валидацией фронтматтера и паритет-тестом в CI.
- RTL — это не разовая правка
dir, а логические CSS-свойства и отдельный проход тестирования, если в списке локалей есть арабский или иврит.
Все пять пунктов звучат как здравый смысл, когда описаны на бумаге. На практике каждый нарушался хотя бы раз в одном из трёх проектов, прежде чем оформился в правило.



