Approfondimenti
Sito multilingua con Next.js: 22 lingue, il caso GAGA
Routing per lingua, serbo in latino e cirillico, formattazione Intl, font, CI delle traduzioni e tassi aggiornati: lezioni da un sito Next.js in 22 lingue.
Team di engineering di sigmacode.io10 min di lettura
In questa pagina (11)
- A ogni lingua il proprio URL
- Due alfabeti, una lingua
- Numeri, valute e date: formattateli con Intl
- Font e copertura dei glifi
- Un workflow di traduzione che regge 22 lingue
- Dati in tempo reale senza valori obsoleti
- Un'unica fonte di verità per PDF, Excel, CSV e XML
- SEO su molte lingue
- Accessibilità
- Test: route × lingue
- Che cosa mettere in conto
GAGA Menjačnica è un cambiavalute e rivenditore di metalli preziosi con diverse filiali a Novi Sad, in Serbia. Il nostro team ha realizzato la sua piattaforma, menjacnicegaga.rs, con Next.js e React su Vercel. Funziona in 22 lingue, tra cui serbo in alfabeto sia latino sia cirillico, tedesco, cinese, russo, turco, ucraino, greco e bulgaro. Mostra i tassi di acquisto e vendita in tempo reale accanto ai tassi di riferimento della Banca nazionale di Serbia e include un convertitore di valuta, listini cambi scaricabili in cinque formati, la ricerca delle filiali e guide all'oro e all'argento da investimento.
Con ventidue lingue l'internazionalizzazione smette di essere una funzionalità. A questa scala determina l'architettura. Questo articolo tratta ciò che secondo noi conta quando si costruisce una piattaforma del genere. È scritto per CTO, product owner e sviluppatori frontend che ne stanno pianificando una. Per il progetto in sé, rimandiamo al case study GAGA.
A ogni lingua il proprio URL#
La decisione più importante viene per prima: ogni versione linguistica di ogni pagina ha bisogno di un URL proprio, stabile e scansionabile dai crawler. Non cambiate lingua con un cookie né leggendo Accept-Language. I motori di ricerca non possono indicizzarlo, gli utenti non possono condividerlo e le CDN non possono metterlo in cache in modo pulito.
Consigliamo un prefisso di lingua nel percorso, come /sr/..., /de/... e /zh/.... Con l'App Router di Next.js questo significa un segmento [locale] alla radice dell'app. Il proxy delle richieste (proxy.ts, in precedenza middleware) può reindirizzare la prima visita verso un default sensato in base alla lingua del browser. Da lì in poi l'URL è l'unica fonte di verità, e il selettore di lingua deve puntare alla stessa pagina nell'altra lingua, non alla home page dell'altra lingua.
Pianificate presto gli identificatori delle lingue e usate ovunque i tag BCP 47. Il serbo da solo ne richiede due, sr-Latn e sr-Cyrl. Mapparli in un unico punto a segmenti di URL, valori hreflang e locale di Intl risparmia molti problemi in seguito.
hreflang, x-default e sitemap#
Ogni pagina dovrebbe elencare tutte le proprie alternative, compresa se stessa, e aggiungere un x-default per gli utenti di cui non supportate la lingua. Nell'App Router ci pensa la 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 },
};
}
Generate la sitemap a partire dallo stesso elenco di lingue e dallo stesso registro delle route, con le alternative per ogni voce. Quando tag hreflang e voci della sitemap nascono da percorsi di codice separati, prima o poi si contraddicono, e i motori di ricerca smettono in silenzio di fidarsi di entrambi.
Due alfabeti, una lingua#
Il serbo si scrive sia in alfabeto latino sia in cirillico, e molti lettori hanno una preferenza netta. Trattateli come due locale a pieno titolo, non come un solo locale con un interruttore di visualizzazione. Ciascuno ha il proprio URL, il proprio valore hreflang e il proprio attributo lang.
La tentazione è scrivere tutto in un alfabeto e traslitterare l'altro in automatico. Per il testo corrente, una traslitterazione ben testata può essere un punto di partenza ragionevole. Ma non applicatela alla cieca a tutto:
- Nomi propri e brand. Nomi di aziende, nomi di prodotti e parole straniere conservano spesso la forma latina anche nel testo in cirillico. Un convertitore meccanico li trasformerà «premurosamente» in qualcosa che nessuno ha mai scritto.
- Ambiguità dei digrammi. I digrammi latini
nj,ljedžcorrispondono di solito a una sola lettera cirillica, ma non sempre. Parole composte e prestiti stranieri fanno eccezione. Dal cirillico al latino la conversione è deterministica. Dal latino al cirillico no. - URL, codici e identificatori. I codici valuta come
EUR, gli indirizzi e-mail, gli slug e tutto ciò che sta dentro i placeholder di interpolazione non vanno mai traslitterati. - Ricerca e ordinamento. Gli utenti possono digitare in caratteri latini nel campo di ricerca di una pagina in cirillico. Normalizzate entrambi i lati prima del confronto.
Che cosa consigliamo: conservate la sorgente in cirillico (la direzione deterministica), oppure mantenete due cataloghi separati, e tenete un piccolo elenco di eccezioni di cui sia responsabile un madrelingua. Tutto ciò che è stato prodotto da una macchina va revisionato prima del lancio.
Numeri, valute e date: formattateli con Intl#
Su un sito di tassi di cambio, i numeri sono il prodotto. Il serbo usa la virgola come separatore decimale, il tedesco raggruppa le migliaia con il punto e gli utenti cinesi si aspettano convenzioni ancora diverse. Non formattate a mano. Usate le API Intl native e passate il tag di lingua completo:
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)
Alcuni aspetti da mettere in conto. Impostate il fuso orario in modo esplicito. Altrimenti le pagine renderizzate sul server usano il fuso del server, che di solito è UTC. Create i formatter una volta sola e riutilizzateli, perché costruirli di continuo in una tabella grande ha un costo che si somma. E fissate il numero di decimali nel data layer oltre che nella UI, così esportazioni e schermo coincidono.
Font e copertura dei glifi#
Ventidue lingue significano caratteri latini, latino esteso (serbo, croato, turco), cirillico (serbo, russo, ucraino, bulgaro), greco e CJK. Pochi caratteri tipografici di brand coprono tutto questo, e quelli che lo fanno sono pesanti.
Che cosa conta:
- Verificate la copertura per ogni sistema di scrittura prima di scegliere un carattere. Testate stringhe reali, non «Lorem ipsum». Il cirillico serbo ha lettere proprie e forme corsive locali, l'ucraino ha lettere che al russo mancano e il bulgaro preferisce forme dei glifi proprie.
- Create subset per sistema di scrittura e caricateli in base alla lingua.
next/fontsupporta subset comelatin,latin-ext,cyrillicegreek. Una pagina in tedesco non deve scaricare glifi cirillici. - Non ospitate in proprio un font CJK completo su ogni pagina. I font cinesi possono pesare diversi megabyte. Uno stack di font di sistema per il CJK, come
"PingFang SC", "Microsoft YaHei", "Noto Sans SC", sans-serif, è spesso il compromesso giusto. - Progettate uno stack di fallback esplicito e impostate fallback con metriche compatibili, così il testo non si sposta quando il web font viene caricato.
Un workflow di traduzione che regge 22 lingue#
Con due o tre lingue bastano un foglio di calcolo e un po' di disciplina. Con 22 serve una pipeline.
Cataloghi di messaggi con chiavi stabili. Usate un file JSON per lingua, con chiavi che esprimono il significato, come rates.table.buy, e mai il testo inglese. Usate ICU MessageFormat per plurali e interpolazione. Le regole del plurale differiscono molto: russo, ucraino e serbo hanno più forme, il cinese nessuna.
Parità delle chiavi in CI. Una chiave mancante in una lingua è il bug più comune di un sito multilingua, ed è anche il più facile da intercettare in automatico:
// 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);
Estendete lo stesso script per verificare che i placeholder di interpolazione coincidano in tutte le lingue. Il nome di un placeholder tradotto per errore si rompe a runtime, non in fase di build.
Traduzione assistita dall'AI, con revisione umana. La traduzione automatica e quella con LLM producono ormai buone prime bozze, e con 22 lingue questo cambia i conti. Ma un cambiavalute ha a che fare con denaro e fiducia. Le etichette dei tassi, le note legali e le guide all'investimento vanno revisionate da una persona che padroneggi la lingua prima di andare online. Date contesto ai traduttori: screenshot, limiti di caratteri e un glossario dei termini fissi come «tasso di acquisto», «tasso di vendita» e «tasso di riferimento».
Dati in tempo reale senza valori obsoleti#
I tassi di cambio variano nel corso della giornata. La trappola è metterli in cache con la stessa aggressività del resto di un sito Next.js static-first.
Le strategie generali che consigliamo:
- Separate la struttura dai dati. Layout della pagina, traduzioni e guide possono essere statici o rivalidati di rado. La tabella dei tassi deve avere una propria finestra di rivalidazione breve, oppure essere caricata sul client o in streaming.
- Tenete la rivalidazione breve e ponderata. Scegliete una finestra che corrisponda alla frequenza reale con cui cambiano i tassi e documentatela. Dove possibile, usate la rivalidazione on-demand alla pubblicazione di nuovi tassi invece di affidarvi solo a un timer.
- Cache sull'edge, con attenzione. Un
s-maxagebreve constale-while-revalidatemantiene le pagine veloci, ma assicuratevi che la finestra di obsolescenza sia accettabile per il business. - Mostrate sempre data e ora di aggiornamento («aggiornato al»), formattate secondo la lingua e nel fuso orario della filiale. È la risposta onesta alla domanda che ogni sistema di cache solleva: quanto è fresco questo dato?
Un'unica fonte di verità per PDF, Excel, CSV e XML#
GAGA pubblica il proprio listino cambi per il download in PDF, JPG, Excel, CSV e XML. Cinque formati creano cinque occasioni perché i numeri non coincidano.
La regola: costruite ogni esportazione dalla stessa struttura dati normalizzata, la stessa che genera la tabella a schermo. Mettete arrotondamento, ordinamento e metadati delle valute in quella struttura, non nei singoli exporter. Ogni formato diventa così un serializer sottile. Aspetti da mettere in conto:
- Il CSV richiede un delimitatore e una codifica dichiarati. In molte impostazioni locali europee Excel si aspetta il punto e virgola e gestisce meglio l'UTF-8 con un BOM.
- Excel deve ricevere vere celle numeriche con formati numerici, non stringhe preformattate, così gli utenti possono farci i calcoli.
- L'XML richiede uno schema stabile e documentato, perché qualcuno ci costruirà sopra un'integrazione.
- PDF e immagini devono incorporare font che coprano il sistema di scrittura richiesto, il che riporta alla copertura dei glifi.
Inserite data e ora di aggiornamento anche in ogni esportazione.
SEO su molte lingue#
Oltre a hreflang e sitemap:
- Traducete title, description e metadati Open Graph per ogni lingua. Non lasciate metadati in inglese su una pagina in greco.
- Localizzate gli slug solo se riuscite a mantenerli stabili. Uno slug cambiato in una lingua significa redirect e un cluster hreflang rotto.
- Evitate i duplicati con poco contenuto. Se una lingua ha solo contenuti parziali, valutate se debba già essere indicizzata.
- Gli URL canonical devono puntare alla pagina stessa, mai a un'altra versione linguistica.
Accessibilità#
Impostate lang sull'elemento html per ogni lingua, usando il tag completo (sr-Latn, sr-Cyrl), così gli screen reader scelgono la voce e la pronuncia giuste. Quando in una pagina compare un'espressione in un'altra lingua, marcatela con il suo lang.
Anche se oggi non supportate una lingua da destra a sinistra, mettetela in conto. Usate le proprietà logiche CSS come margin-inline-start al posto di margin-left e ricavate dir dal locale. Aggiungere l'arabo in seguito costa molto meno se il layout non dà per scontata la direzione da sinistra a destra.
Test: route × lingue#
Con 22 lingue, un bug che compare in una sola è facile da non vedere. Consigliamo uno smoke test automatizzato che cicli su ogni route pubblica e su ogni lingua e controlli le basi:
- la pagina restituisce 200 e viene renderizzata senza errori a runtime;
htmlha illangcorretto;- le alternative hreflang sono complete e puntano a URL esistenti;
- non sono visibili chiavi di messaggio grezze (come
rates.table.buy) né stringhe vuote; - convertitore e tabella dei tassi mostrano i numeri nel formato atteso.
Aggiungete snapshot visivi per le lingue più lunghe. Le etichette in tedesco e in greco rompono spesso layout che in inglese sembravano a posto.
Che cosa mettere in conto#
Se state avviando una piattaforma Next.js multilingua, queste sono le decisioni da prendere il primo giorno: una strategia di URL con prefisso di lingua, un unico registro delle lingue che governi routing, metadati e sitemap, una policy chiara su alfabeti e traslitterazione, Intl per tutta la formattazione, un piano dei font per sistema di scrittura, controlli in CI sulla parità delle traduzioni, una policy esplicita sulla freschezza dei dati in tempo reale e un'unica fonte di verità per ogni esportazione.
Niente di tutto questo è esotico, ma costa molto meno progettarlo dall'inizio che aggiungerlo a posteriori. Il nostro lavoro è guidato da un tech lead con oltre 20 anni di esperienza, ed è su questo tipo di fondamenta che ci concentriamo in Web & piattaforme. Se state pianificando qualcosa di simile, contattateci: rispondiamo entro 24 ore nei giorni lavorativi.