Nell’articolo di oggi voglio parlarvi di come ho scelto di gestire le traduzioni in una webapp Angular 21 molto complessa (più di 300 rotte). L’applicazione in questione doveva gestire il multilingua by-design e la scelta di come gestire questo aspetto non è assolutamente da sottovalutare: scelte sbagliate porterebbero a cattive performance e soprattutto tornare indietro a “correggere” ci costerebbe molto (in termini di tempo o di token).
Prima di addentrarci sulle scelte tecniche, capiamo l’anatomia dell’applicazione
Architettura applicazione: monorepo con NX
Quando ci troviamo davanti alla necessità di dover costruire un’applicazione enterprise abbiamo principalmente due scelte: monorepo o microfrontend.

I microfrontend rimangono la scelta che meno preferisco per tutta una serie di motivazioni e l’applicazione dove ho implementato questo era (è) un monorepo gestito con NX, una libreria nata per gestire questa tipologia di applicazione.
All’interno del monorepo avevo suddiviso i molti domini in vertical slicing, ognuno dei quali suddiviso in 5 diverse librerie:
data-accessper gestire l’accesso ai dati (service verso le api)featuresper contenere gli smart component e le feature del dominiouiper gestire i dumb components riutilizzabilimodelsper gestire i modelli del dominioutilsper contenere funzioni pure di utilità
Questa suddivisione per ogni dominio può sembrare eccessiva e poco incline al principio Keep-It-Simple-Stupid, ma con il crescere dell’applicazione questa struttura ci permette di raggiungere, insieme al lazy loading, delle ottime performance di caricamento, in quanto l’applicazione carica in modalità differita solo ciò che serve e quando serve, evitando download di bundle inutili.
Struttura delle traduzioni
Avere una struttura standard e decisa prima di essere nel bel mezzo degli sviluppi non è solo una cosa buona e giusta, ma è anche quello che ti salva dal prendere decisioni affrettate e sbagliate quando la pressione dello sviluppo si fa sentire. Per questo progetto la struttura del file delle traduzioni aveva questa struttura json:
{
"context": "common",
"version": "1.0.0",
"translations": {
// all'interno di questo nodo vado a suddividere le
// traduzioni per componenti della UI.
"ui": {
"component-1": {
"label-1": "your translation 1",
"label-2": "your translation 2",
"label-3": "your translation 3",
// [...]
}
},
// Qui definisco le traduzioni di tutte le property degli oggetti
"models": {
"model-1": {
"property-a": "Proprietà A",
"property-b": "Proprietà B"
// [...]
}
},
// Qui definisco tutti i messaggi informativi/di errore/di warning ritornati all'utente
"messages": { },
// Qui definisco i messaggi di validazioni di funzioni o property
"validation": { }
}
}
Segmentazione traduzioni
Ora che abbiamo visto come è stato strutturato un file delle traduzioni, proviamo ad immaginare quanto questo possa diventare enorme man mano che l’applicazione ed i componenti cresceranno: questo significa che l’utente, ogni volta che aprirà l’applicazione per la prima volta, dovrà scaricare un json enorme prima di poter visualizzare la pagina richiesta.
Il nostro obiettivo è di andare a “spezzare” le traduzioni in tante piccole parti, ognuna delle quali sarà scaricata solamente quando l’utente ne avrà bisogno.
Provate ad immaginare di avere un json delle traduzioni dedicato alla sezione di admin: questa sezione non sarà visibile a tutti gli utenti e sarebbe sbagliato se tutte le traduzioni di questa sezione fossero in un json che tutti gli utenti scaricherebbero.
Come in molte altre cose non esiste la ricetta perfetta per capire come suddividere le traduzioni, io ho adottato la seguente strategia:
- Un json di traduzioni “comuni” (es. errori generici, frasi generiche di salvataggio, traduzioni di oggetti condivisi in tutta l’applicazione)
- Un json per ogni vertical slice: ogni volta che l’utente navigherà un dominio scaricherà uno specifico file di traduzioni
In tutto questo teniamo presente che un componente potrebbe contenere anche elementi di altri domini, in questo caso il componente potrà utilizzare le traduzioni di più domini.
Passiamo ora alla libreria che ho scelto, per le traduzioni
Libreria i18next
i18next è un’ottima libreria nata per gestire il multilingua nel mondo javascript e permette di gestire alcuni aspetti molto interessanti: plurali, interpolazione di stringhe, stringhe composte, contesti.
Quando richiamiamo una stringa di traduzione in un componente possiamo appendere alla chiave il nome del dominio, separato da due punti, e successivamente la chiave. La pipe i18next è necessaria nel componente html affinché Angular risolva la chiave anziché renderizzare la stringa letterale:
//componente.html
<p>{{ "domain:my.key.path" | i18next }}</p>
//componente.ts
let translated = i18next.t('domain:my.key.path');
Come gestiamo il download delle traduzioni? Beh in realtà ci pensa in automatico i18next: procediamo con ordine e vediamo come l’ho configurato nel progetto.
A livello di progetto è configurato nel seguente modo: nel file app.config.ts, dove andiamo a definire i provider globali dell’applicazione, andiamo ad inizializzare il servizio globale tramite provideI18Next e provideAppInitializer. Separare le due responsabilità è fondamentale: provideI18Next registra la libreria nel DI di Angular, mentre provideAppInitializer garantisce che l’inizializzazione sia completata prima che l’applicazione cominci a renderizzare, evitando il flickering dei testi.
// [...]
export function appInitI18n() {
return () => {
const localization = inject(LocalizationService);
return localization.init();
};
}
export function createAppConfig(loadResult: IEnvironmentLoadResult): ApplicationConfig {
return {
providers: [
// [...]
provideAppInitializer(appInitI18n()),
provideI18Next(withCustomErrorHandlingStrategy(StrictErrorHandlingStrategy))
// [...]
]
}
}
Successivamente ho creato un servizio (con providedIn: 'root', sempre attivo) dove ho centralizzato la configurazione di i18next. La configurazione è la seguente:
// [...]
import i18next, { TFunction } from 'i18next';
import HttpApi from 'i18next-http-backend';
import Backend from 'i18next-chained-backend';
import LocalStorageBackend from 'i18next-localstorage-backend';
@Injectable({
providedIn: 'root',
})
export class LocalizationService {
private readonly defaultTranslationNamespace = "common";
private readonly _i18nConfig = {
// this.userLang() è un signal con la lingua di default dell'utente
lng: this.userLang(),
fallbackLng: 'it-IT',
// namespace caricato di default
ns: [this.defaultTranslationNamespace],
defaultNS: this.defaultTranslationNamespace,
// Caricamento parziale delle traduzioni
partialBundledLanguages: true,
// Abilitare saveMissing solo in ambiente di sviluppo/staging,
// in causerebbe richieste HTTP per ogni chiave mancante (per ogni utente)
saveMissing: !environment.production,
backend: {
backends: [
// Prima cerca le traduzioni in cache nel localstorage
LocalStorageBackend,
// Se non le trova carica da endpoint
HttpApi,
],
backendOptions: [
// LocalStorageBackend options (definito in backends)
{},
// HttpApi options (definito in backends)
{
// Endpoint da richiamare se manca una chiave
addPath: `${this.env.api}/api/locales/missing/{{lng}}/{{ns}}`,
// Endpoint per caricare lingua e namespace
loadPath: `${this.env.api}/api/locales/{{lng}}/{{ns}}`,
},
],
},
};
/**
* Inizializzazione di i18next
*/
public init(): Promise<TFunction> {
return i18next.use(Backend).init(this._i18nConfig);
}
/**
* Carica la traduzione di uno specifico namespace o di una lista di namespace
*/
public async loadNamespaceTranslations(namespace: string | string[]): Promise<void> {
const namespaces = Array.isArray(namespace) ? namespace : [namespace];
const unloaded = namespaces.filter(ns => !i18next.hasLoadedNamespace(ns));
//se il namespace è già caricato risolvo la promise
if (unloaded.length === 0) return Promise.resolve();
return i18next.loadNamespaces(unloaded).then(() =>
this._logger.debug(`Caricate traduzioni per i namespace '${unloaded.join(', ')}'`)
);
}
Con questa configurazione abbiamo impostato alcune cose interessanti:
- Ci sono due endpoint di backend: uno è quello dedicato a scaricare la traduzione e uno invece è l’endpoint che i18next chiamerà se una chiave di traduzione è mancante. Lato backend andremo a segnalare sul sistema di monitoraggio o di log un warning riguardo a questo problema: è importante che questo aspetto non sia attivo in produzione sennò ogni utente manderebbe una segnalazione
- nella configurazione
backendabbiamo configurato due plugin: uno per mantenere le traduzioni in cache nellocalStorageed il secondo che serve proprio per caricarle da un endpoint http (in alternativa avremmo potuto mantenerle nel progetto) partialBundledLanguagesci permette di caricare le traduzioni man mano che sono richieste e non tutte insieme
Infine, per abbattere ulteriormente i tempi di attesa e di caricamento, ho aggiunto una funzione da richiamare nei resolver delle rotte per pre-caricare la traduzione prima che la rotta sia navigata. La funzione del resolver è quella di eseguire della logica prima che la rotta venga attivata: attenzione, non è buona cosa aggiungere troppa logica qui, perché andremmo a rallentare molto il caricamento del componente. Nella pratica quanto vi ho descritto si traduce così:
export const myDomainRoutes: Route[] = [
{
path: 'list',
// Componente caricato in modalità lazy
loadComponent: () => import('@my-app/domain-one/features').then((module) => module.FeatureList),
resolve: { translations: translationLoaderResolver },
data: {
// Qui definisco quali namespace voglio che siano pre-caricati
// possono essere più di uno perché un componente potrebbe avere
// traduzioni da più domini
namespace: [DomainsEnum.COMMON, DomainsEnum.DOMAIN_ONE],
},
}
}
export const translationLoaderResolver: ResolveFn<boolean> = (route) => {
// Inject del servizio tramite Dependency Injection
const localizations = inject(LocalizationService);
// Ottieni il namespace dal route data o usa un default
const namespace = route.data?.['namespace'] || 'common';
// Carica le traduzioni dal backend
return from(localizations.loadNamespaceTranslations(namespace)).pipe(
map(() => true),
catchError((error) => {
// Log in caso di errore
return of(false);
}),
);
};
Riepilogo workflow
Ricapitolando quanto abbiamo visto fino adesso quello che succede sotto al cofano di Angular è il seguente workflow:
- Avvio app Angular
- Istanziato servizio i18n
- Istanziato servizio
LocalizationServicecon scope globale, che si occupa di creare la configurazione di i18next
- L’utente naviga una rotta
- Prima di navigarla si attiva la funzione definita nel resolver, che carica la lista di namespace definita nel campo
data.namespacedella rotta. Il download avviene secondo le modalità configurate nelLocalizationService, tramite endpoint. Se la traduzione è già nel localStorage non viene fatto nulla - Il componente si carica in modalità lazy e si ritrova già le traduzioni, questo evita situazioni di stallo (dovuto al caricamento) o flickering.
- Prima di navigarla si attiva la funzione definita nel resolver, che carica la lista di namespace definita nel campo
Conclusioni
Gestire il multilingua in un’applicazione enterprise non è mai una scelta da prendere alla leggera: come abbiamo visto, dietro a un semplice testo tradotto si nasconde tutta una serie di decisioni architetturali che, se prese con criterio fin dall’inizio, ci ripagano con performance migliori e un codice più mantenibile. La combinazione di i18next, segmentazione delle traduzioni per vertical slice e pre-caricamento nei resolver mi ha permesso di tenere i bundle leggeri e di caricare solo ciò che serve, quando serve. Come sempre non esiste una ricetta valida per tutti i progetti, ma spero che questo approccio possa darvi un punto di partenza per le vostre applicazioni.
Alla prossima!