Przejdź do treści

Wiedza

Next.js i18n w 22 językach: wnioski z platformy GAGA

Routing locale, serbski łacinką i cyrylicą, formatowanie Intl, fonty, CI tłumaczeń i świeże kursy walut: wnioski z budowy platformy Next.js w 22 językach.

Zespół inżynierów sigmacode.io10 min czytania

Na tej stronie (11)
  1. Daj każdej wersji językowej własny URL
  2. Dwa alfabety, jeden język
  3. Liczby, waluty i daty formatuj przez Intl
  4. Fonty i pokrycie glifów
  5. Proces tłumaczeń, który wytrzyma 22 języki
  6. Dane w czasie rzeczywistym bez nieaktualnych wartości
  7. Jedno źródło prawdy dla PDF, Excela, CSV i XML
  8. SEO w wielu wersjach językowych
  9. Dostępność
  10. Testy: trasy × locale
  11. Co zaplanować

GAGA Menjačnica to kantor i sprzedawca metali szlachetnych z kilkoma oddziałami w Nowym Sadzie w Serbii. Nasz zespół zbudował jej platformę, menjacnicegaga.rs, w Next.js i React na Vercel. Działa w 22 językach, w tym po serbsku w alfabecie łacińskim i cyrylicą, po niemiecku, chińsku, rosyjsku, turecku, ukraińsku, grecku i bułgarsku. Pokazuje aktualne kursy kupna i sprzedaży obok kursów referencyjnych Narodowego Banku Serbii, a do tego ma przelicznik walut, tabele kursów do pobrania w pięciu formatach, wyszukiwarkę oddziałów oraz poradniki o złocie i srebrze inwestycyjnym.

Przy dwudziestu dwóch językach internacjonalizacja przestaje być funkcją. W tej skali kształtuje architekturę. W tym artykule opisujemy to, co naszym zdaniem jest najważniejsze przy budowie takiej platformy. Piszemy go dla CTO, product ownerów i frontend developerów, którzy planują podobny projekt. O samym projekcie przeczytasz w case study GAGA.

Daj każdej wersji językowej własny URL#

Najważniejsza decyzja zapada na początku: każda wersja językowa każdej strony potrzebuje własnego, stabilnego adresu URL, który da się crawlować. Nie przełączaj języka ciasteczkiem ani na podstawie nagłówka Accept-Language. Wyszukiwarki tego nie zaindeksują, użytkownicy nie udostępnią, a CDN nie zcache’uje w czysty sposób.

Polecamy prefiks wersji językowej w ścieżce, na przykład /sr/..., /de/... i /zh/.... W App Routerze Next.js oznacza to segment [locale] w korzeniu aplikacji. Proxy żądań (proxy.ts, dawniej middleware) może przy pierwszej wizycie przekierować na rozsądną wersję domyślną na podstawie języka przeglądarki. Od tego momentu jedynym źródłem prawdy jest URL, a przełącznik języka musi prowadzić do tej samej strony w innej wersji językowej, a nie na jej stronę główną.

Identyfikatory locale zaplanuj wcześnie i wszędzie używaj tagów BCP 47. Sam serbski potrzebuje dwóch: sr-Latn i sr-Cyrl. Zmapowanie ich w jednym miejscu na segmenty URL, wartości hreflang i locale dla Intl oszczędza później wielu kłopotów.

hreflang, x-default i mapy witryny#

Każda strona powinna wymieniać wszystkie swoje alternatywy, łącznie z samą sobą, i dodawać x-default dla użytkowników, których języka nie obsługujesz. W App Routerze zrobi to za Ciebie 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 },
  };
}

Mapę witryny buduj z tej samej listy locale i tego samego rejestru tras, z alternatywami dla każdego wpisu. Gdy tagi hreflang i wpisy w sitemapie powstają w osobnych ścieżkach kodu, prędzej czy później zaczną się rozjeżdżać, a wyszukiwarki po cichu przestaną ufać jednym i drugim.

Dwa alfabety, jeden język#

Serbski zapisuje się zarówno alfabetem łacińskim, jak i cyrylicą, a wielu czytelników ma wyraźne preferencje. Traktuj je jako dwa pełnoprawne locale, a nie jedno locale z przełącznikiem wyświetlania. Każde dostaje własny URL, własną wartość hreflang i własny atrybut lang.

Kusi, żeby wszystko pisać jednym alfabetem, a drugi uzyskiwać automatyczną transliteracją. Dla tekstu ciągłego dobrze przetestowana transliteracja może być rozsądnym punktem wyjścia. Nie stosuj jej jednak na ślepo do wszystkiego:

  • Nazwy własne i marki. Nazwy firm, produktów i wyrazy obce często zachowują łacińską formę nawet w tekście pisanym cyrylicą. Mechaniczny konwerter „uprzejmie” zamieni je w coś, czego nikt nie napisał.
  • Niejednoznaczność dwuznaków. Łacińskie nj, lj i zwykle odpowiadają pojedynczym literom cyrylicy, ale nie zawsze. Regułę łamią złożenia i zapożyczenia. Kierunek z cyrylicy na łacinkę jest deterministyczny. Z łacinki na cyrylicę – nie.
  • URL-e, kody i identyfikatory. Kodów walut takich jak EUR, adresów e-mail, slugów ani niczego wewnątrz placeholderów interpolacji nie wolno transliterować nigdy.
  • Wyszukiwanie i sortowanie. Użytkownik może wpisać tekst łacinką w wyszukiwarce na stronie w cyrylicy. Znormalizuj obie strony przed porównaniem.

Nasza rekomendacja: przechowuj źródło w cyrylicy (kierunek deterministyczny) albo utrzymuj dwa osobne katalogi i prowadź krótką listę wyjątków, za którą odpowiada native speaker. Wszystko, co wyprodukowała maszyna, powinien przed startem przejrzeć człowiek.

Liczby, waluty i daty formatuj przez Intl#

W serwisie z kursami walut liczby są produktem. Serbski używa przecinka jako separatora dziesiętnego, niemiecki grupuje tysiące kropką, a użytkownicy z Chin oczekują jeszcze innych konwencji. Nie formatuj ręcznie. Użyj wbudowanych API Intl i przekazuj pełny tag locale:

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)

Kilka rzeczy, które warto zaplanować. Ustaw strefę czasową jawnie. W przeciwnym razie strony renderowane na serwerze użyją strefy serwera, czyli zwykle UTC. Formatery twórz raz i używaj ponownie, bo konstruowanie ich w kółko w dużej tabeli się sumuje. Liczbę miejsc po przecinku trzymaj też w warstwie danych, nie tylko w UI, żeby eksporty zgadzały się z ekranem.

Fonty i pokrycie glifów#

Dwadzieścia dwa języki to znaki łacińskie, Latin Extended (serbski, chorwacki, turecki), cyrylica (serbski, rosyjski, ukraiński, bułgarski), greka i znaki CJK. Niewiele krojów firmowych pokrywa to wszystko, a te, które pokrywają, są duże.

Co jest ważne:

  • Sprawdź pokrycie każdego pisma, zanim wybierzesz krój. Testuj na prawdziwych tekstach, nie na „Lorem ipsum”. Serbska cyrylica ma własne litery i lokalne formy kursywy, ukraiński ma litery, których brakuje rosyjskiemu, a bułgarski preferuje własne kształty glifów.
  • Dziel fonty na podzbiory według pisma i ładuj je według locale. next/font obsługuje podzbiory takie jak latin, latin-ext, cyrillic i greek. Strona po niemiecku nie powinna pobierać glifów cyrylicy.
  • Nie hostuj samodzielnie pełnego fontu CJK na każdej stronie. Chińskie fonty potrafią ważyć kilka megabajtów. Systemowy stos fontów dla CJK, na przykład "PingFang SC", "Microsoft YaHei", "Noto Sans SC", sans-serif, to często właściwy kompromis.
  • Zaprojektuj jawny stos fallbacków i ustaw fallbacki zgodne metrycznie, żeby tekst nie skakał, gdy załaduje się web font.

Proces tłumaczeń, który wytrzyma 22 języki#

Przy dwóch czy trzech językach wystarczy arkusz kalkulacyjny i trochę dyscypliny. Przy 22 potrzebujesz pipeline’u.

Katalogi komunikatów ze stabilnymi kluczami. Jeden plik JSON na locale, z kluczami nazwanymi według znaczenia, jak rates.table.buy, a nigdy według angielskiego tekstu. Do liczby mnogiej i interpolacji używaj ICU MessageFormat. Reguły liczby mnogiej bardzo się różnią: rosyjski, ukraiński i serbski mają po kilka form, a chiński nie ma żadnej.

Zgodność kluczy w CI. Brakujący klucz w jednym locale to najczęstszy błąd wielojęzycznego serwisu, a zarazem najłatwiejszy do automatycznego wyłapania:

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);

Rozszerz ten sam skrypt o sprawdzanie, czy placeholdery interpolacji zgadzają się we wszystkich locale. Przetłumaczona nazwa placeholdera wysypie się w runtime, a nie podczas builda.

Tłumaczenie wspomagane przez AI, z weryfikacją człowieka. Tłumaczenie maszynowe i modele LLM dają dziś dobre pierwsze wersje, a przy 22 językach to zmienia rachunek ekonomiczny. Kantor obraca jednak pieniędzmi i zaufaniem. Etykiety kursów, informacje prawne i poradniki inwestycyjne powinna przed publikacją przejrzeć osoba biegle znająca język. Daj tłumaczom kontekst: zrzuty ekranu, limity znaków i glosariusz stałych terminów, takich jak „kurs kupna”, „kurs sprzedaży” i „kurs referencyjny”.

Dane w czasie rzeczywistym bez nieaktualnych wartości#

Kursy walut zmieniają się w ciągu dnia. Pułapką jest cache’owanie ich równie agresywnie jak reszty serwisu Next.js nastawionego na statyczne renderowanie.

Ogólne strategie, które polecamy:

  • Oddziel szkielet od danych. Layout strony, tłumaczenia i poradniki mogą być statyczne albo rewalidowane rzadko. Tabela kursów powinna mieć własne, krótkie okno rewalidacji albo być pobierana po stronie klienta lub streamowana.
  • Rewalidacja krótka i przemyślana. Wybierz okno, które odpowiada temu, jak często kursy faktycznie się zmieniają, i je udokumentuj. Tam, gdzie to możliwe, stosuj rewalidację na żądanie w chwili publikacji nowych kursów, zamiast polegać wyłącznie na timerze.
  • Cache’uj na edge z rozwagą. Krótkie s-maxage ze stale-while-revalidate utrzymuje szybkość stron, ale upewnij się, że okno nieaktualności jest akceptowalne dla biznesu.
  • Zawsze pokazuj znacznik czasu „stan na”, sformatowany według locale, w strefie czasowej oddziału. To uczciwa odpowiedź na pytanie, które rodzi każdy system cache’owania: jak świeże są te dane?

Jedno źródło prawdy dla PDF, Excela, CSV i XML#

GAGA udostępnia tabelę kursów do pobrania jako PDF, JPG, Excel, CSV i XML. Pięć formatów to pięć okazji, żeby liczby się nie zgadzały.

Zasada: każdy eksport buduj z tej samej znormalizowanej struktury danych, tej samej, z której renderuje się tabela na ekranie. Zaokrąglanie, kolejność i metadane walut umieść w tej strukturze, a nie w poszczególnych eksporterach. Każdy format staje się wtedy cienkim serializerem. O czym warto pamiętać:

  • CSV wymaga jasno określonego separatora i kodowania. Excel w wielu europejskich ustawieniach regionalnych oczekuje średnika i lepiej radzi sobie z UTF-8, gdy plik ma BOM.
  • Excel powinien dostać prawdziwe komórki liczbowe z formatami liczb, a nie sformatowane wcześniej napisy, żeby użytkownicy mogli na nich liczyć.
  • XML potrzebuje stabilnego, udokumentowanego schematu, bo ktoś na pewno się z nim zintegruje.
  • PDF i obrazy muszą osadzać fonty pokrywające dane pismo, co prowadzi nas z powrotem do pokrycia glifów.

Znacznik czasu „stan na” umieść również w każdym eksporcie.

SEO w wielu wersjach językowych#

Poza hreflang i mapami witryny:

  • Tłumacz tytuły, opisy i metadane Open Graph dla każdego locale. Nie zostawiaj angielskich metadanych na greckiej stronie.
  • Slugi lokalizuj tylko wtedy, gdy potrafisz utrzymać je stabilne. Zmieniony slug w jednym locale oznacza przekierowania i rozbity klaster hreflang.
  • Unikaj ubogich duplikatów. Jeśli dana wersja językowa ma tylko część treści, zastanów się, czy powinna już być indeksowana.
  • Adresy kanoniczne powinny wskazywać na samą stronę, nigdy na inną wersję językową.

Dostępność#

Ustaw lang na elemencie html dla każdego locale, używając pełnego tagu (sr-Latn, sr-Cyrl), żeby czytniki ekranu dobierały właściwy głos i wymowę. Gdy na stronie pojawia się fraza w innym języku, oznacz ją własnym lang.

Nawet jeśli dziś nie obsługujesz języka pisanego od prawej do lewej, zaplanuj to. Używaj logicznych właściwości CSS, takich jak margin-inline-start zamiast margin-left, a dir wyprowadzaj z locale. Dodanie arabskiego w przyszłości jest znacznie tańsze, gdy layout nie zakłada kierunku od lewej do prawej.

Testy: trasy × locale#

Przy 22 locale łatwo przeoczyć błąd, który pojawia się tylko w jednym z nich. Polecamy automatyczny smoke test, który przechodzi po każdej publicznej trasie i każdym locale i sprawdza podstawy:

  • strona zwraca 200 i renderuje się bez błędów w runtime;
  • html ma poprawny lang;
  • alternatywy hreflang są kompletne i wskazują na istniejące adresy URL;
  • nie widać surowych kluczy komunikatów (jak rates.table.buy) ani pustych napisów;
  • przelicznik i tabela kursów renderują liczby w oczekiwanym formacie.

Dodaj snapshoty wizualne dla najdłuższych języków. Niemieckie i greckie etykiety często rozbijają layouty, które po angielsku wyglądały dobrze.

Co zaplanować#

Jeśli zaczynasz wielojęzyczną platformę w Next.js, oto decyzje do podjęcia pierwszego dnia: strategia URL z prefiksem locale, jeden rejestr locale, który steruje routingiem, metadanymi i mapami witryny, jasna polityka dotycząca alfabetów i transliteracji, Intl do całego formatowania, plan fontów dla każdego pisma, sprawdzanie zgodności tłumaczeń w CI, jawna polityka świeżości danych na żywo oraz jedno źródło prawdy dla każdego eksportu.

Nic z tego nie jest egzotyczne, ale znacznie taniej jest to zaprojektować od początku, niż dorabiać po fakcie. Nasze prace prowadzi tech lead z ponad 20-letnim doświadczeniem, a właśnie na takich fundamentach skupiamy się w obszarze Web i platformy. Jeśli planujesz coś podobnego, odezwij się.

Masz pomysł na projekt?

Opowiedz nam, co budujesz. Zwykle w ciągu kilku dni roboczych dostajesz uczciwą ocenę, jasny zakres i ofertę w stałej cenie albo z rozliczeniem za kamienie milowe.

Wolisz najpierw napisać? Napisz do nas

Twój rozmówca: Ing. Ismet Mesic, Tech lead.