Статьи
Next.js-платформа на 22 языках: уроки проекта GAGA
Маршрутизация по локалям, сербский на латинице и кириллице, форматирование через Intl, шрифты, CI для переводов и актуальные курсы валют: уроки сборки Next.js-платформы на 22 языках.
Инженерная команда sigmacode.io10 мин чтения
На этой странице (11)
- У каждой локали — свой URL
- Две системы письма, один язык
- Форматируйте числа, валюты и даты через Intl
- Шрифты и покрытие глифов
- Процесс перевода, который выдерживает 22 языка
- Данные в реальном времени без устаревших значений
- Единый источник истины для PDF, Excel, CSV и XML
- SEO в множестве локалей
- Доступность
- Тестирование: маршруты × локали
- Что нужно спланировать
GAGA Menjačnica — обменник валют и дилер драгоценных металлов с несколькими отделениями в Нови-Саде, Сербия. Наша команда построила их платформу, menjacnicegaga.rs, на Next.js и React на Vercel. Она работает на 22 языках, включая сербский на латинице и кириллице, немецкий, китайский, русский, турецкий, украинский, греческий и болгарский. Сайт показывает живые курсы покупки и продажи рядом с референсными курсами Национального банка Сербии и включает конвертер валют, списки курсов для скачивания в пяти форматах, поиск отделений и руководства по инвестиционному золоту и серебру.
Двадцать два языка — это уже за гранью, где интернационализация просто фича. В таком масштабе она формирует архитектуру. Эта статья — о том, что, по нашему мнению, важно при построении подобной платформы. Она написана для CTO, продакт-оунеров и фронтенд-инженеров, которые планируют такой проект. О самом проекте читайте в кейсе GAGA.
У каждой локали — свой URL#
Самое важное решение принимается первым: каждой языковой версии каждой страницы нужен собственный стабильный, индексируемый URL. Не переключайте язык через cookie или сниффинг Accept-Language. Поисковые системы не смогут это индексировать, пользователи — делиться ссылками, а CDN — чисто кешировать.
Мы рекомендуем префикс локали в пути, например /sr/..., /de/... и /zh/.... В Next.js App Router это означает сегмент [locale] в корне приложения. Прокси запросов (proxy.ts, ранее middleware) может перенаправить первый визит на разумную локаль по умолчанию на основе языка браузера. После этого URL — единственный источник истины, а переключатель языка должен вести на ту же страницу в другой локали, а не на главную страницу другой локали.
Планируйте идентификаторы локалей заранее и везде используйте теги BCP 47. Одному сербскому нужно два: sr-Latn и sr-Cyrl. Сопоставление их с сегментами URL, значениями hreflang и локалями Intl в одном месте избавит от многих проблем позже.
hreflang, x-default и карты сайта#
Каждая страница должна перечислять все свои альтернативные версии, включая себя, и добавлять x-default для пользователей, чей язык вы не поддерживаете. В App Router это делает metadata API:
// app/[locale]/rates/page.tsx
import type { Metadata } from "next";
const BASE = "https://example.com";
const LOCALES = ["sr-Latn", "sr-Cyrl", "en", "de", "zh", "ru"] as const;
const segment = (l: string) => l.toLowerCase(); // "sr-Latn" -> "sr-latn"
export async function generateMetadata({ params }: { params: Promise<{ locale: string }> }): Promise<Metadata> {
const { locale } = await params;
const path = "/rates";
const languages: Record<string, string> = Object.fromEntries(
LOCALES.map((l) => [l, `${BASE}/${segment(l)}${path}`])
);
languages["x-default"] = `${BASE}/en${path}`;
return {
alternates: { canonical: `${BASE}/${locale}${path}`, languages },
};
}
Стройте карту сайта из того же списка локалей и того же реестра маршрутов, с альтернативами для каждой записи. Как только hreflang-теги и записи карты сайта идут из разных кусков кода, они рано или поздно разойдутся, и поисковые системы тихо перестанут доверять обоим.
Две системы письма, один язык#
Сербский пишется и латиницей, и кириллицей, и у многих читателей есть стойкое предпочтение. Относитесь к ним как к двум полноценным локалям, а не как к одной локали с переключателем отображения. У каждой — свой URL, своё значение hreflang и свой атрибут lang.
Заманчиво писать всё в одной системе письма и транслитерировать другую автоматически. Для сплошного текста хорошо протестированный этап транслитерации может быть разумной отправной точкой. Но не применяйте её слепо ко всему:
- Имена собственные и бренды. Названия компаний, продуктов и иностранные слова часто сохраняют латинское написание даже в кириллическом тексте. Механический конвертер «услужливо» превратит их в то, чего никто не писал.
- Неоднозначность диграфов. Латинские
nj,ljиdžобычно соответствуют одной кириллической букве, но не всегда. Составные слова и иностранные заимствования ломают правило. Конвертация из кириллицы в латиницу детерминирована. Из латиницы в кириллицу — нет. - URL, коды и идентификаторы. Коды валют вроде
EUR, адреса электронной почты, слаги и всё внутри плейсхолдеров интерполяции нельзя транслитерировать никогда. - Поиск и сортировка. Пользователи могут вводить латиницу в поле поиска на кириллической странице. Нормализуйте обе стороны перед сравнением.
Что мы рекомендуем: храните кириллический оригинал (детерминированное направление) или ведите два отдельных каталога и держите небольшой список исключений, за который отвечает носитель языка. Всё, что произвела машина, должно быть проверено до запуска.
Форматируйте числа, валюты и даты через Intl#
На сайте обменных курсов числа и есть продукт. В сербском десятичный разделитель — запятая, в немецком тысячи группируются точкой, а китайские пользователи ожидают иных конвенций. Не форматируйте вручную. Используйте встроенные API Intl и передавайте полный тег локали:
const rateFormatter = (locale: string) =>
new Intl.NumberFormat(locale, {
minimumFractionDigits: 4,
maximumFractionDigits: 4,
});
const moneyFormatter = (locale: string, currency: string) =>
new Intl.NumberFormat(locale, { style: "currency", currency });
const asOf = (locale: string, date: Date) =>
new Intl.DateTimeFormat(locale, {
dateStyle: "long",
timeStyle: "short",
timeZone: "Europe/Belgrade",
}).format(date);
rateFormatter("sr-Latn").format(117.1234); // "117,1234"
rateFormatter("de").format(117.1234); // "117,1234"
moneyFormatter("en", "EUR").format(1250); // "€1,250.00"
asOf("sr-Cyrl", new Date()); // e.g. "18. септембар 2026. 10:30" (exact output depends on the ICU version)
Несколько моментов, которые нужно предусмотреть. Задавайте часовой пояс явно — иначе серверно отрендеренные страницы используют зону сервера, обычно UTC. Создавайте форматтеры один раз и переиспользуйте, потому что их повторное создание в большой таблице накапливается. И держите число десятичных знаков согласованным в слое данных и в UI, чтобы экспорты и экран совпадали.
Шрифты и покрытие глифов#
Двадцать два языка — это латиница, Latin Extended (сербский, хорватский, турецкий), кириллица (сербский, русский, украинский, болгарский), греческий и иероглифы CJK. Немного фирменных шрифтов покрывают всё это, а те, что покрывают, — тяжёлые.
Что важно:
- Проверяйте покрытие по каждой системе письма до выбора шрифта. Тестируйте на реальных строках, а не на «Lorem ipsum». В сербской кириллице есть свои буквы и локальные курсивные формы, в украинском — буквы, которых нет в русском, а болгарский предпочитает собственные начертания глифов.
- Делайте сабсеты по системам письма и загружайте по локали.
next/fontподдерживает сабсеты вродеlatin,latin-ext,cyrillicиgreek. Немецкая страница не должна скачивать кириллические глифы. - Не хостите полный CJK-шрифт на каждой странице. Китайские шрифты могут весить несколько мегабайт. Системный стек шрифтов для CJK, например
"PingFang SC", "Microsoft YaHei", "Noto Sans SC", sans-serif, — часто правильный компромисс. - Спроектируйте явный стек запасных шрифтов и задайте метрически совместимые fallback'и, чтобы текст не сдвигался при загрузке веб-шрифта.
Процесс перевода, который выдерживает 22 языка#
При двух-трёх языках достаточно таблицы и дисциплины. При 22 нужен конвейер.
Каталоги сообщений со стабильными ключами. Один JSON-файл на локаль, ключи названы по смыслу — как rates.table.buy, — и никогда по английскому тексту. Используйте ICU MessageFormat для плюрализации и интерполяции. Правила множественного числа сильно различаются: в русском, украинском и сербском несколько форм, а в китайском их нет.
Паритет ключей в CI. Отсутствующий ключ в одной из локалей — самый частый баг многоязычного сайта, и его проще всего ловить автоматически:
// scripts/check-i18n.ts — run in CI, fail the build on drift
import { readdirSync, readFileSync } from "node:fs";
const dir = "messages";
const flatten = (o: Record<string, unknown>, p = ""): string[] =>
Object.entries(o).flatMap(([k, v]) =>
v && typeof v === "object" ? flatten(v as Record<string, unknown>, `${p}${k}.`) : [`${p}${k}`]
);
const load = (f: string) => new Set(flatten(JSON.parse(readFileSync(`${dir}/${f}`, "utf8"))));
const source = load("en.json");
let failed = false;
for (const file of readdirSync(dir).filter((f) => f.endsWith(".json") && f !== "en.json")) {
const keys = load(file);
const missing = [...source].filter((k) => !keys.has(k));
const extra = [...keys].filter((k) => !source.has(k));
if (missing.length || extra.length) {
failed = true;
console.error(`${file}: missing ${missing.length}, extra ${extra.length}`, { missing, extra });
}
}
process.exit(failed ? 1 : 0);
Расширьте тот же скрипт проверкой, что плейсхолдеры интерполяции совпадают между локалями. Переведённое имя плейсхолдера ломается в рантайме, а не при сборке.
Перевод с помощью ИИ и человеческим ревью. Машинный и LLM-перевод теперь дают хорошие первые черновики, и при 22 языках это меняет экономику. Но обменник работает с деньгами и доверием. Подписи курсов, юридические уведомления и инвестиционные гайды должны проверять свободно владеющие языком люди до публикации. Давайте переводчикам контекст: скриншоты, ограничения по длине и глоссарий фиксированных терминов вроде «курс покупки», «курс продажи» и «референсный курс».
Данные в реальном времени без устаревших значений#
Курсы валют меняются в течение дня. Ловушка — кешировать их так же агрессивно, как остальной статически-ориентированный Next.js-сайт.
Общие стратегии, которые мы рекомендуем:
- Отделите оболочку от данных. Разметка страницы, переводы и гайды могут быть статичными или ревалидироваться редко. Таблица курсов должна иметь собственное короткое окно ревалидации — или запрашиваться на клиенте, или стримиться.
- Делайте ревалидацию короткой и осознанной. Выбирайте окно, соответствующее реальной частоте изменения курсов, и документируйте его. Где возможно, используйте ревалидацию по требованию при публикации новых курсов, а не полагайтесь только на таймер.
- Кешируйте на edge осторожно. Короткий
s-maxageсоstale-while-revalidateдержит страницы быстрыми, но убедитесь, что окно устаревания приемлемо для бизнеса. - Всегда показывайте метку «по состоянию на», отформатированную по локали в часовом поясе отделения. Это честный ответ на вопрос, который ставит любая система кеширования: насколько это свежо?
Единый источник истины для PDF, Excel, CSV и XML#
GAGA публикует список курсов для скачивания в PDF, JPG, Excel, CSV и XML. Пять форматов — пять шансов для чисел разойтись.
Правило: стройте каждый экспорт из одной и той же нормализованной структуры данных — той самой, что рендерит таблицу на экране. Округление, порядок и метаданные валют размещайте в этой структуре, а не в каждом экспортёре. Тогда каждый формат становится тонким сериализатором. Что предусмотреть:
- CSV требует указанного разделителя и кодировки. Excel во многих европейских локалях ожидает точку с запятой и лучше работает с UTF-8 при наличии BOM.
- Excel должен получать настоящие числовые ячейки с числовыми форматами, а не заранее отформатированные строки, чтобы пользователи могли с ними считать.
- XML нужна стабильная, задокументированная схема, потому что кто-то будет по ней интегрироваться.
- PDF и изображения должны встраивать шрифты, покрывающие нужную систему письма, — и это возвращает вас к покрытию глифов.
Помещайте метку «по состоянию на» и в каждый экспорт.
SEO в множестве локалей#
Помимо hreflang и карт сайта:
- Переводите заголовки, описания и метаданные Open Graph для каждой локали. Не оставляйте английские метаданные на греческой странице.
- Локализуйте слаги, только если можете сохранить их стабильными. Изменённый слаг в одной локали означает редиректы и сломанный кластер hreflang.
- Избегайте пустых дублей. Если в локали контент частичный, подумайте, стоит ли её вообще индексировать.
- Канонические URL должны указывать на саму страницу, никогда — на другую языковую версию.
Доступность#
Устанавливайте lang на элементе html для каждой локали, используя полный тег (sr-Latn, sr-Cyrl), чтобы скринридеры выбирали правильный голос и произношение. Когда на странице встречается фраза на другом языке, помечайте её собственным lang.
Даже если сегодня вы не поддерживаете язык с письмом справа налево, планируйте это. Используйте логические CSS-свойства вроде margin-inline-start вместо margin-left и выводите dir из локали. Добавить арабский позже гораздо дешевле, когда вёрстка не предполагает направление слева направо.
Тестирование: маршруты × локали#
При 22 локалях баг, проявляющийся только в одной из них, легко пропустить. Мы рекомендуем автоматический смоук-тест, который проходит по каждому публичному маршруту и каждой локали и проверяет базовые вещи:
- страница возвращает 200 и рендерится без ошибок в рантайме;
- у
htmlправильныйlang; - hreflang-альтернативы полны и указывают на существующие URL;
- не видно сырых ключей сообщений (вроде
rates.table.buy) или пустых строк; - конвертер и таблица курсов рендерят числа в ожидаемом формате.
Добавьте визуальные снапшоты для самых длинных языков. Немецкие и греческие подписи часто ломают вёрстку, которая в английском выглядела нормально.
Что нужно спланировать#
Если вы начинаете многоязычную Next.js-платформу, вот решения, которые нужно принять в первый день: стратегия URL с префиксом локали, единый реестр локалей, управляющий маршрутизацией, метаданными и картами сайта, чёткая политика по системам письма и транслитерации, Intl для всего форматирования, план шрифтов по каждой системе письма, CI-проверки паритета переводов, явная политика свежести живых данных и единый источник истины для каждого экспорта.
Ничего из этого не экзотика, но заложить это в архитектуру гораздо дешевле, чем наращивать задним числом. Нашей работой руководит техлид с более чем 20-летним опытом, и именно на таком фундаменте мы сосредоточены в направлении Web & Platforms. Если вы планируете что-то подобное, свяжитесь с нами.