Перейти к содержимому

Статьи

Next.js-платформа на 22 языках: уроки проекта GAGA

Маршрутизация по локалям, сербский на латинице и кириллице, форматирование через Intl, шрифты, CI для переводов и актуальные курсы валют: уроки сборки Next.js-платформы на 22 языках.

Инженерная команда sigmacode.io10 мин чтения

На этой странице (11)
  1. У каждой локали — свой URL
  2. Две системы письма, один язык
  3. Форматируйте числа, валюты и даты через Intl
  4. Шрифты и покрытие глифов
  5. Процесс перевода, который выдерживает 22 языка
  6. Данные в реальном времени без устаревших значений
  7. Единый источник истины для PDF, Excel, CSV и XML
  8. SEO в множестве локалей
  9. Доступность
  10. Тестирование: маршруты × локали
  11. Что нужно спланировать

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:

ts
// 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 и передавайте полный тег локали:

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

ts
// 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. Если вы планируете что-то подобное, свяжитесь с нами.

Есть проект в голове?

Расскажите, что строите. В рабочие дни senior-инженер отвечает в течение часа — затем вы получаете честную оценку, понятный объём и предложение с фиксированной ценой или по вехам, обычно за несколько рабочих дней.

Сначала написать? Напишите нам

Вы будете говорить напрямую с Ing. Ismet Mesic, Tech lead.