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)
- Daj każdej wersji językowej własny URL
- Dwa alfabety, jeden język
- Liczby, waluty i daty formatuj przez Intl
- Fonty i pokrycie glifów
- Proces tłumaczeń, który wytrzyma 22 języki
- Dane w czasie rzeczywistym bez nieaktualnych wartości
- Jedno źródło prawdy dla PDF, Excela, CSV i XML
- SEO w wielu wersjach językowych
- Dostępność
- Testy: trasy × locale
- 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:
// 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,ljidž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:
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/fontobsługuje podzbiory takie jaklatin,latin-ext,cyrillicigreek. 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:
// 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-maxagezestale-while-revalidateutrzymuje 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;
htmlma poprawnylang;- 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ę.