Naar de inhoud

Insights

Meertalige website met Next.js: 22 talen, lessen uit GAGA

Locale-routing, hreflang, Servisch in Latijns en cyrillisch schrift, Intl-opmaak, fonts, vertaal-CI en verse koersen: lessen uit een Next.js-site in 22 talen.

Engineeringteam van sigmacode.io10 min leestijd

Op deze pagina (11)
  1. Geef elke locale een eigen URL
  2. Twee schriften, één taal
  3. Formatteer getallen, valuta en datums met Intl
  4. Fonts en glyph-dekking
  5. Een vertaalworkflow die 22 talen overleeft
  6. Realtime data zonder verouderde waarden
  7. Eén bron van waarheid voor PDF, Excel, CSV en XML
  8. SEO over veel locales
  9. Toegankelijkheid
  10. Testen: routes × locales
  11. Waar je rekening mee moet houden

GAGA Menjačnica is een wisselkantoor en handelaar in edelmetalen met meerdere vestigingen in Novi Sad, Servië. Ons team bouwde hun platform, menjacnicegaga.rs, met Next.js en React op Vercel. Het draait in 22 talen, waaronder Servisch in zowel Latijns als cyrillisch schrift, Duits, Chinees, Russisch, Turks, Oekraïens, Grieks en Bulgaars. Het toont actuele aan- en verkoopkoersen naast de referentiekoersen van de Nationale Bank van Servië, en het bevat een valutacalculator, koerslijsten die je in vijf formaten kunt downloaden, een vestigingszoeker en gidsen over beleggingsgoud en -zilver.

Bij tweeëntwintig talen is internationalisatie geen feature meer. Op die schaal bepaalt het de architectuur. Dit artikel behandelt wat er volgens ons toe doet als je zo'n platform bouwt. Het is geschreven voor CTO's, product owners en frontend-engineers die er een aan het plannen zijn. Meer over het project zelf lees je in de case over GAGA.

Geef elke locale een eigen URL#

De belangrijkste beslissing komt eerst: elke taalversie van elke pagina heeft een eigen stabiele, crawlbare URL nodig. Wissel niet van taal met een cookie of door Accept-Language uit te lezen. Zoekmachines kunnen dat niet indexeren, gebruikers kunnen het niet delen en CDN's kunnen het niet netjes cachen.

Wij raden een locale-prefix in het pad aan, zoals /sr/..., /de/... en /zh/.... Met de App Router van Next.js betekent dat een [locale]-segment in de root van de app. De request-proxy (proxy.ts, voorheen middleware) kan een eerste bezoek op basis van de browsertaal doorsturen naar een zinnige standaard. Daarna is de URL de enige bron van waarheid, en een taalwissel moet linken naar dezelfde pagina in de andere locale, niet naar de homepage van die locale.

Leg je locale-identifiers vroeg vast en gebruik overal BCP 47-tags. Servisch alleen al heeft er twee nodig, sr-Latn en sr-Cyrl. Als je de koppeling met URL-segmenten, hreflang-waarden en Intl-locales op één plek bijhoudt, bespaar je jezelf later veel gedoe.

hreflang, x-default en sitemaps#

Elke pagina hoort al haar alternatieven te vermelden, inclusief zichzelf, plus een x-default voor gebruikers van wie je de taal niet ondersteunt. In de App Router regelt de metadata-API dat voor je:

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 },
  };
}

Bouw je sitemap op uit dezelfde lijst met locales en hetzelfde routeregister, met alternatieven bij elke entry. Zodra hreflang-tags en sitemap-entries uit verschillende codepaden komen, gaan ze elkaar vroeg of laat tegenspreken, en dan vertrouwen zoekmachines ze stilletjes allebei niet meer.

Twee schriften, één taal#

Servisch wordt zowel in Latijns als in cyrillisch schrift geschreven, en veel lezers hebben een uitgesproken voorkeur. Behandel ze als twee volwaardige locales, niet als één locale met een weergaveschakelaar. Elk krijgt een eigen URL, een eigen hreflang-waarde en een eigen lang-attribuut.

Het is verleidelijk om alles in één schrift te schrijven en het andere automatisch te translitereren. Voor lopende tekst kan een goed geteste transliteratiestap een redelijk vertrekpunt zijn. Maar pas hem niet blind op alles toe:

  • Eigennamen en merken. Bedrijfsnamen, productnamen en buitenlandse woorden houden vaak hun Latijnse vorm, ook in cyrillische tekst. Een mechanische converter maakt er ‘behulpzaam’ iets van wat niemand ooit heeft geschreven.
  • Dubbelzinnige digrafen. De Latijnse nj, lj en komen meestal overeen met één cyrillische letter, maar niet altijd. Samenstellingen en leenwoorden doorbreken de regel. Van cyrillisch naar Latijns is deterministisch. Van Latijns naar cyrillisch is dat niet.
  • URL's, codes en identifiers. Valutacodes zoals EUR, e-mailadressen, slugs en alles wat in interpolatie-placeholders staat, mag nooit worden getranslitereerd.
  • Zoeken en sorteren. Gebruikers typen op een cyrillische pagina soms Latijns in een zoekveld. Normaliseer beide kanten voordat je vergelijkt.

Ons advies: bewaar de cyrillische bron (de deterministische richting), of houd twee aparte catalogi bij, en onderhoud een korte uitzonderingenlijst waarvoor een moedertaalspreker verantwoordelijk is. Alles wat een machine heeft geproduceerd, hoort vóór de lancering te worden nagekeken.

Formatteer getallen, valuta en datums met Intl#

Op een site met wisselkoersen zijn de getallen het product. Servisch gebruikt een komma als decimaalteken, Duits groepeert duizendtallen met een punt, en Chinese gebruikers verwachten weer andere conventies. Formatteer niet met de hand. Gebruik de ingebouwde Intl-API's en geef de volledige locale-tag mee:

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)

Een paar dingen om rekening mee te houden. Stel de tijdzone expliciet in. Pagina's die op de server worden gerenderd gebruiken anders de zone van de server, en dat is meestal UTC. Maak formatters één keer aan en hergebruik ze, want ze in een grote tabel steeds opnieuw construeren tikt aan. En leg het aantal decimalen zowel in de datalaag als in de UI vast, zodat exports en scherm met elkaar kloppen.

Fonts en glyph-dekking#

Tweeëntwintig talen betekent Latijns, Latin Extended (Servisch, Kroatisch, Turks), cyrillisch (Servisch, Russisch, Oekraïens, Bulgaars), Grieks en CJK-tekens. Weinig huisstijlfonts dekken dat allemaal, en de fonts die het wel doen zijn groot.

Waar het om gaat:

  • Controleer de dekking per schrift voordat je een lettertype kiest. Test met echte strings, niet met ‘Lorem ipsum’. Servisch cyrillisch heeft eigen letters en lokale cursiefvormen, Oekraïens heeft letters die het Russisch niet kent, en Bulgaars geeft de voorkeur aan eigen glyphvormen.
  • Maak subsets per schrift en laad per locale. next/font ondersteunt subsets zoals latin, latin-ext, cyrillic en greek. Een Duitse pagina hoort geen cyrillische glyphs te downloaden.
  • Host niet op elke pagina zelf een volledig CJK-font. Chinese fonts kunnen meerdere megabytes groot zijn. Een stack van systeemfonts voor CJK, zoals "PingFang SC", "Microsoft YaHei", "Noto Sans SC", sans-serif, is vaak de juiste afweging.
  • Ontwerp een expliciete fallback-stack en stel fallbacks met compatibele metrics in, zodat tekst niet verspringt wanneer het webfont laadt.

Een vertaalworkflow die 22 talen overleeft#

Met twee of drie talen volstaan een spreadsheet en wat discipline. Met 22 heb je een pipeline nodig.

Message catalogs met stabiele keys. Gebruik één JSON-bestand per locale, met keys die naar betekenis zijn genoemd, zoals rates.table.buy, en nooit naar de Engelse tekst. Gebruik ICU MessageFormat voor meervouden en interpolatie. Meervoudsregels lopen sterk uiteen: Russisch, Oekraïens en Servisch hebben meerdere vormen, en Chinees heeft er geen.

Key-pariteit in CI. Een ontbrekende key in één locale is de meest voorkomende bug op een meertalige site, en ook de bug die je het makkelijkst automatisch vangt:

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

Breid hetzelfde script uit met een controle of de interpolatie-placeholders in alle locales overeenkomen. Een vertaalde placeholdernaam gaat pas stuk bij runtime, niet tijdens de build.

Vertalen met AI, met menselijke review. Machinevertaling en LLM's leveren inmiddels goede eerste versies op, en bij 22 talen verandert dat de rekensom. Maar een wisselkantoor draait om geld en vertrouwen. Koerslabels, juridische mededelingen en beleggingsgidsen horen door iemand die de taal vloeiend beheerst te worden nagekeken voordat ze live gaan. Geef vertalers context: screenshots, tekenlimieten en een woordenlijst met vaste termen zoals ‘aankoopkoers’, ‘verkoopkoers’ en ‘referentiekoers’.

Realtime data zonder verouderde waarden#

Wisselkoersen veranderen in de loop van de dag. De valkuil is dat je ze net zo agressief cachet als de rest van een static-first Next.js-site.

Algemene strategieën die we aanraden:

  • Scheid de shell van de data. De paginalay-out, vertalingen en gidsen kunnen statisch zijn of zelden worden gerevalideerd. De koerstabel hoort een eigen, kort revalidatievenster te hebben, of op de client te worden opgehaald of gestreamd.
  • Houd revalidatie kort en doordacht. Kies een venster dat past bij hoe vaak de koersen werkelijk veranderen, en documenteer het. Gebruik waar mogelijk on-demand revalidatie zodra er nieuwe koersen worden gepubliceerd, in plaats van alleen op een timer te vertrouwen.
  • Cache met beleid aan de edge. Een korte s-maxage met stale-while-revalidate houdt pagina's snel, maar zorg dat het stale-venster er een is waar het bedrijf mee kan leven.
  • Toon altijd een tijdstempel ‘bijgewerkt op’, per locale geformatteerd in de tijdzone van de vestiging. Het is het eerlijke antwoord op de vraag die elk cachingsysteem oproept: hoe vers is dit?

Eén bron van waarheid voor PDF, Excel, CSV en XML#

GAGA publiceert zijn koerslijst als download in PDF, JPG, Excel, CSV en XML. Vijf formaten zijn vijf kansen dat de getallen niet met elkaar kloppen.

De regel: bouw elke export op uit dezelfde genormaliseerde datastructuur, dezelfde die ook de tabel op het scherm rendert. Leg afronding, volgorde en valutametadata in die structuur vast, niet in elke exporter. Elk formaat wordt dan een dunne serializer. Dingen om rekening mee te houden:

  • CSV heeft een expliciet scheidingsteken en een expliciete encoding nodig. Excel verwacht in veel Europese locales een puntkomma en gaat beter met UTF-8 om als er een BOM in staat.
  • Excel hoort echte numerieke cellen met getalnotaties te krijgen, geen vooraf geformatteerde strings, zodat gebruikers ermee kunnen rekenen.
  • XML heeft een stabiel, gedocumenteerd schema nodig, want iemand gaat ertegen integreren.
  • PDF en afbeeldingen moeten fonts insluiten die het gevraagde schrift dekken, en daarmee ben je terug bij glyph-dekking.

Zet het tijdstempel ‘bijgewerkt op’ ook in elke export.

SEO over veel locales#

Naast hreflang en sitemaps:

  • Vertaal titels, descriptions en Open Graph-metadata per locale. Laat geen Engelse metadata op een Griekse pagina staan.
  • Lokaliseer slugs alleen als je ze stabiel kunt houden. Een gewijzigde slug in één locale betekent redirects en een gebroken hreflang-cluster.
  • Vermijd dunne duplicaten. Als een locale maar een deel van de content heeft, vraag je dan af of die al geïndexeerd moet worden.
  • Canonical URL's horen naar de pagina zelf te verwijzen, nooit naar een andere taalversie.

Toegankelijkheid#

Zet voor elke locale lang op het html-element, met de volledige tag (sr-Latn, sr-Cyrl), zodat schermlezers de juiste stem en uitspraak kiezen. Als er binnen een pagina een zinsdeel in een andere taal voorkomt, markeer dat dan met een eigen lang.

Ook als je vandaag geen taal ondersteunt die van rechts naar links wordt geschreven: houd er rekening mee. Gebruik logische CSS-property's zoals margin-inline-start in plaats van margin-left, en leid dir af van de locale. Later Arabisch toevoegen is veel goedkoper als de lay-out niet uitgaat van links-naar-rechts.

Testen: routes × locales#

Met 22 locales zie je een bug die maar in één ervan opduikt makkelijk over het hoofd. Wij raden een geautomatiseerde smoketest aan die over elke publieke route en elke locale loopt en de basis controleert:

  • de pagina geeft 200 terug en rendert zonder runtime-fouten;
  • html heeft de juiste lang;
  • de hreflang-alternatieven zijn compleet en verwijzen naar URL's die bestaan;
  • er zijn geen kale message keys (zoals rates.table.buy) of lege strings zichtbaar;
  • de calculator en de koerstabel tonen getallen in het verwachte formaat.

Voeg visuele snapshots toe voor de langste talen. Duitse en Griekse labels breken vaak lay-outs die er in het Engels prima uitzagen.

Waar je rekening mee moet houden#

Als je aan een meertalig Next.js-platform begint, zijn dit de beslissingen voor dag één: een URL-strategie met locale-prefix, één register van locales dat routing, metadata en sitemaps aanstuurt, een helder beleid voor schriften en transliteratie, Intl voor alle opmaak, een fontplan per schrift, CI-checks op vertaalpariteit, een expliciet beleid voor de versheid van live data en één bron van waarheid voor elke export.

Niets hiervan is exotisch, maar het is veel goedkoper om het vanaf het begin mee te ontwerpen dan om het er achteraf in te bouwen. Ons werk wordt geleid door een tech lead met meer dan 20 jaar ervaring, en dit is het soort fundament waar we ons bij Web & platformen op richten. Plan je iets vergelijkbaars, neem dan contact op.

Heb je een project in gedachten?

Vertel ons wat je bouwt. Een senior engineer reageert op werkdagen binnen 24 uur, en meestal heb je binnen enkele werkdagen een eerlijke inschatting, een heldere scope en een voorstel met een vaste prijs of mijlpalen.

Liever eerst schrijven? Stuur ons een bericht

Je spreekt direct met Ing. Ismet Mesic, Tech lead.