In today’s article, I want to share how I approached translation management in a fairly complex Angular 21 (soon 22) webapp with over 300 routes. The app needed multilanguage support baked in from day one, and picking the right strategy for this is definitely not a trivial decision: wrong choices lead to poor performance, and going back to fix things later would cost a lot (in terms of time or tokens).
Before diving into the technical choices, let’s take a look at the application’s architecture.
Application architecture: monorepo with NX
When building an enterprise application, you mainly have two options: monorepo or microfrontend.

Microfrontends are my least favorite option for a number of reasons, and the application where I implemented this was (and still is) a monorepo managed with NX, a library built specifically for this kind of setup.
Inside the monorepo I split the many domains using vertical slicing, each divided into 5 different libraries:
data-accessto handle data access (services toward the APIs)featuresto contain smart components and domain featuresuito manage reusable dumb componentsmodelsto manage domain modelsutilsto contain pure utility functions
This per-domain structure might look over-engineered and not exactly KISS(Keep-It-Simple-Stupid)-friendly, but as the application grows, it allows us to achieve excellent loading performance alongside lazy loading: the app defers loading only what’s needed, when it’s needed, avoiding unnecessary bundle downloads.
Translation structure
Having a solid, agreed-upon structure before you’re knee-deep in development is not just good practice, it’s what saves you from making rushed and poor decisions when deadlines start creeping in. For this project, the translation file followed this JSON structure:
{
"context": "common",
"version": "1.0.0",
"translations": {
// Inside this node I split translations by UI components
"ui": {
"component-1": {
"label-1": "your translation 1",
"label-2": "your translation 2",
"label-3": "your translation 3",
// [...]
}
},
// Here I define translations for all object properties
"models": {
"model-1": {
"property-a": "Proprietà A",
"property-b": "Proprietà B"
// [...]
}
},
// Here I define all info/error/warning messages returned to the user
"messages": { },
// Here I define validation messages for functions or properties
"validation": { }
}
}
Translation segmentation
Now that we’ve seen how a translation file is structured, imagine how bloated it can get as the application and its components grow. This means every time a user opens the app for the first time, they’d have to download a huge JSON before seeing anything on screen.
The goal is to break translations into smaller chunks, each downloaded only when the user actually needs it.
Think about having a dedicated translations JSON for the admin section: that section isn’t visible to all users, so it would make no sense to include all its translations in a file that every user downloads.
As with many things in software, there’s no perfect formula for splitting translations. Here’s the approach I went with:
- A “common” translations JSON (e.g. generic errors, generic save messages, translations for objects shared across the whole app)
- One JSON per vertical slice: whenever the user navigates to a domain, a specific translation file gets downloaded
Keep in mind that a component might pull in elements from other domains, in which case it can use translations from multiple namespaces.
i18next library
i18next is a solid library built for multilanguage support in the JavaScript ecosystem, and it covers some really useful scenarios: plurals, string interpolation, nested strings, and contexts.
When referencing a translation string in a component, you append the domain name to the key, separated by a colon, followed by the key path itself. The i18next pipe is required in the HTML template so Angular resolves the key instead of rendering the literal string:
//html. component
<p>{{ "domain:my.key.path" | i18next }}</p>
//ts.component
let translated = i18next.t('domain:my.key.path');
How does the translation download work? i18next handles it automatically. Let’s walk through how I configured it for this project.
At the project level, everything is wired up in app.config.ts, where the global providers are defined. We initialize the service using provideI18Next and provideAppInitializer. Keeping these two responsibilities separate matters: provideI18Next registers the library in Angular’s DI, while provideAppInitializer ensures initialization is complete before the app starts rendering, preventing text flickering.
// [...]
export function appInitI18n() {
return () => {
const localization = inject(LocalizationService);
return localization.init();
};
}
export function createAppConfig(loadResult: IEnvironmentLoadResult): ApplicationConfig {
return {
providers: [
// [...]
provideAppInitializer(appInitI18n()),
provideI18Next(withCustomErrorHandlingStrategy(StrictErrorHandlingStrategy))
// [...]
]
}
}
I then created a service (with providedIn: 'root', always active) to centralize the i18next configuration:
// [...]
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() is a signal holding the user's default language
lng: this.userLang(),
fallbackLng: 'it-IT',
// Default loaded namespace
ns: [this.defaultTranslationNamespace],
defaultNS: this.defaultTranslationNamespace,
// Partial loading of translations
partialBundledLanguages: true,
// Enable saveMissing only in dev/staging,
// in production it would fire an HTTP request for every missing key (per user)
saveMissing: !environment.production,
backend: {
backends: [
// First looks for cached translations in localStorage
LocalStorageBackend,
// Falls back to loading from an endpoint
HttpApi,
],
backendOptions: [
// LocalStorageBackend options
{},
// HttpApi options
{
// Endpoint called when a key is missing
addPath: `${this.env.api}/api/locales/missing/{{lng}}/{{ns}}`,
// Endpoint to load language and namespace
loadPath: `${this.env.api}/api/locales/{{lng}}/{{ns}}`,
},
],
},
};
/**
* i18next initialization
*/
public init(): Promise<TFunction> {
return i18next.use(Backend).init(this._i18nConfig);
}
/**
* Loads translations for a specific namespace or a list of namespaces
*/
public async loadNamespaceTranslations(namespace: string | string[]): Promise<void> {
const namespaces = Array.isArray(namespace) ? namespace : [namespace];
const unloaded = namespaces.filter(ns => !i18next.hasLoadedNamespace(ns));
//If the namespace is already loaded, resolve immediately
if (unloaded.length === 0) return Promise.resolve();
return i18next.loadNamespaces(unloaded).then(() =>
this._logger.debug(`Caricate traduzioni per i namespace '${unloaded.join(', ')}'`)
);
}
With this setup, a few interesting things are in play:
- There are two backend endpoints: one for downloading translations, and one that i18next calls when a key is missing. On the backend side, we log a warning to our monitoring system. This should be disabled in production, otherwise every user would trigger a report for every missing key.
- The backend configuration uses two plugins: one to cache translations in localStorage, and another to load them from an HTTP endpoint (alternatively, you could bundle them directly in the project).
partialBundledLanguageslets us load translations on demand rather than all upfront.
To cut down loading times even further, I added a function to call inside route resolvers to pre-load translations before the route activates. The resolver runs logic before a route is activated, but a word of caution: don’t pack too much logic in here, as it would noticeably slow down component loading. Here’s what it looks like in practice:
export const myDomainRoutes: Route[] = [
{
path: 'list',
// Component loaded lazily
loadComponent: () => import('@my-app/domain-one/features').then((module) => module.FeatureList),
resolve: { translations: translationLoaderResolver },
data: {
// Namespaces to pre-load. There can be more than one
// because a component might use translations from multiple domains
namespace: [DomainsEnum.COMMON, DomainsEnum.DOMAIN_ONE],
},
}
}
export const translationLoaderResolver: ResolveFn<boolean> = (route) => {
const localizations = inject(LocalizationService);
const namespace = route.data?.['namespace'] || 'common';
return from(localizations.loadNamespaceTranslations(namespace)).pipe(
map(() => true),
catchError((error) => {
return of(false);
}),
);
};
Workflow recap
Here’s a summary of what happens under the hood:
- Angular app starts
- i18n service is instantiated
LocalizationServiceis instantiated with global scope and builds the i18next configuration
- The user navigates to a route
- Before the navigation completes, the resolver fires and loads the namespaces defined in
data.namespace. The download follows the strategy configured inLocalizationService. If the translation is already in localStorage, nothing happens. - The component loads lazily with translations already in place, avoiding any flickering or loading stalls.
- Before the navigation completes, the resolver fires and loads the namespaces defined in
Conclusions
Handling multilanguage in an enterprise app is never something to rush. As we’ve seen, behind a simple translated string there’s a whole set of architectural decisions that, when made thoughtfully upfront, pay off with better performance and a more maintainable codebase. Combining i18next, per-vertical-slice translation segmentation, and resolver-based pre-loading kept the bundles lean and ensured only the right translations are loaded at the right time. There’s no one-size-fits-all solution, but I hope this gives you a solid starting point for your own projects.
Until next time!