Localize an Angular app: complete guide with Localingos
Angular ships with its own @angular/localize system, but most teams prefer @ngx-translate/core for runtime locale switching (Angular's built-in requires per-locale builds). This guide uses ngx-translate paired with Localingos for the translation pipeline — runtime language switching, lazy-loaded locale files, and automated CI sync.
Step 1 — Install
npm install @ngx-translate/core@^18 @ngx-translate/http-loader@^18
npm install -g localingos
This guide targets @ngx-translate/core v18, the current major. v18 removed TranslateModule in favour of the provide* functions used below, so v16/v17 snippets will not compile against it.
Step 2 — Wire ngx-translate
src/app/app.config.ts:
import { ApplicationConfig } from '@angular/core';
import { provideHttpClient } from '@angular/common/http';
import { provideTranslateService } from '@ngx-translate/core';
import { provideTranslateHttpLoader } from '@ngx-translate/http-loader';
export const appConfig: ApplicationConfig = {
providers: [
provideHttpClient(),
provideTranslateService({
lang: 'en-US',
fallbackLang: 'en-US',
loader: provideTranslateHttpLoader({
prefix: '/assets/i18n/',
suffix: '.json',
}),
}),
],
};
src/app/app.component.ts — initialize on app start:
import { Component, inject, OnInit } from '@angular/core';
import { TranslateService } from '@ngx-translate/core';
@Component({
selector: 'app-root',
templateUrl: './app.component.html',
})
export class AppComponent implements OnInit {
private translate = inject(TranslateService);
// Full BCP 47 codes, matching the files the CLI writes into assets/i18n/.
private readonly supported = ['en-US', 'es-ES', 'de-DE', 'fr-FR'];
ngOnInit() {
this.translate.use(this.resolve(localStorage.getItem('locale') ?? navigator.language));
}
/** Exact tag first, then language subtag — never a bare "en", which has no file. */
private resolve(candidate: string | null): string {
if (!candidate) return 'en-US';
const exact = this.supported.find(l => l.toLowerCase() === candidate.toLowerCase());
if (exact) return exact;
const lang = candidate.split('-')[0].toLowerCase();
return this.supported.find(l => l.split('-')[0].toLowerCase() === lang) ?? 'en-US';
}
}
Step 3 — Source of truth
src/assets/i18n/en-US.json:
{
"welcome": "Welcome, {{name}}",
"cart": "{count, plural, =0 {empty cart} one {1 item} other {{{count}} items}}"
}
ngx-translate supports ICU MessageFormat via the optional @ngx-translate/messageformat-compiler package — recommended if you need plural rules.
Step 4 — Configure Localingos
Run localingos init and answer its prompts. It writes two files.
localingos.config.json — commit this. Project settings shared with your team and CI:
{
"projectId": "your-project-id",
"sourceLocale": "en-US",
"format": "json-nested",
"sourceFile": "src/assets/i18n/en-US.json",
"outputDir": "src/assets/i18n",
"outputPattern": "{locale}.json"
}
.localingos.json — add this to .gitignore. Your API key for local development:
{ "apiKey": "your-api-key" }
In CI, set LOCALINGOS_API_KEY instead; it takes precedence over both files. init is interactive, so for containers, provisioning scripts or AI agents, write these two files yourself — the CLI reads nothing else.
Three things worth knowing:
formatdecides your key shape.json-nestedmapshome.titleto{ "home": { "title": … } };json-flatkeeps"home.title"as one top-level key. Those are the two supported values.- Target locales are not configured here. They belong to the project itself — set them in the dashboard under Projects → Edit → Locales → Update Project. The CLI writes one file per target locale the project has, so you add a language without touching your repo. Codes are full BCP 47, e.g.
es-ES,pt-BR,zh-TW. - Placeholder preservation is automatic. Localingos extracts placeholders from the source string —
{{name}},{name},${count},%s,%d, ICU fragments — and validates each one survives translation, retrying with a corrective prompt when it doesn't. There is nothing to configure.
Then push your source strings and pull back translations:
localingos sync
Translation is asynchronous
The first sync of a new key pushes it and usually has nothing to pull back yet:
Push: 10 created, 0 updated, 0 deleted, 0 unchanged
✅ 0 translations received
⏳ 10 keys pending translation: home.title, home.subtitle, …
No new translations. Run "localingos sync" again later.
That's expected. Run localingos sync (or localingos pull) again shortly to collect results. Unchanged strings are never re-translated or re-billed.
Verify completeness before committing. pull writes whatever is ready and exits 0, so a file pulled mid-translation can be missing keys with no warning and no error — at runtime that surfaces as a silent fallback to your source language. Check key counts per locale before you commit, and gate on it in CI.
Translations land in src/assets/i18n/. These ship as static assets, fetched on demand by TranslateHttpLoader.
Step 5 — Use in components
Template:
<header>
<h1>{{ 'welcome' | translate: { name: userName } }}</h1>
<p>{{ 'cart' | translate: { count: itemCount } }}</p>
</header>
Component class (if you need to translate in TS code):
import { inject } from '@angular/core';
import { TranslateService } from '@ngx-translate/core';
export class CartComponent {
private translate = inject(TranslateService);
get warningMessage() {
return this.translate.instant('cart.warning', { count: this.itemCount });
}
}
Step 6 — Language switcher
import { Component, inject } from '@angular/core';
import { TranslateService } from '@ngx-translate/core';
@Component({
selector: 'app-language-switcher',
template: `
<select [value]="translate.currentLang" (change)="switch($event)">
<option value="en">English</option>
<option value="es">Español</option>
<option value="de">Deutsch</option>
<option value="fr">Français</option>
</select>
`,
})
export class LanguageSwitcher {
translate = inject(TranslateService);
switch(e: Event) {
const locale = (e.target as HTMLSelectElement).value;
this.translate.use(locale);
localStorage.setItem('locale', locale);
}
}
Step 7 — Lazy loading
TranslateHttpLoader already loads on demand — this.translate.use('es') fetches /assets/i18n/es.json only the first time that locale is requested. After the first fetch it's cached client-side.
For build-time bundle splitting (rare — most Angular apps prefer runtime loading), you can wire the loader to dynamically import locale modules instead.
Step 8 — Automate sync in CI
# .github/workflows/i18n.yml
name: i18n-sync
on:
push: { branches: [main], paths: ['src/assets/i18n/en-US.json'] }
jobs:
sync:
runs-on: ubuntu-latest
permissions: { contents: write, pull-requests: write }
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with: { node-version: 20 }
- run: npm install -g localingos
- run: localingos sync
env: { LOCALINGOS_API_KEY: '${{ secrets.LOCALINGOS_API_KEY }}' }
- uses: peter-evans/create-pull-request@v6
with:
branch: i18n/auto-sync
title: 'chore(i18n): sync translations'
commit-message: 'chore(i18n): sync translations'
Production checklist
-
AOT-compile compatible. ngx-translate works with
ng build --configuration productionout of the box. -
Make sure your locale files are actually served. Angular CLI 17+ scaffolds
public/as the asset root and a new app has nosrc/assetsentry inangular.json. If you keep translations insrc/assets/i18n/, add the mapping yourself or every locale fetch 404s at runtime:"assets": [ { "glob": "**/*", "input": "src/assets", "output": "assets", "ignore": ["**/*.descriptions.json"] } ]The
ignoreentry matters:en-US.descriptions.jsonholds internal notes for the translator, and without it that file is published and publicly fetchable. -
SSR (Angular Universal). Use
provideChildTranslateService()with a server-side loader that reads JSON from disk instead of HTTP. Otherwise SSR requests trying to fetch their own translations cause loops. (TranslateModule.forChild()is the pre-v18 equivalent and no longer exists.) -
RTL. Angular cannot bind attributes on
<html>—app-rootsits inside<body>— so set it imperatively when the locale changes, and match on the language subtag, since your locales are full BCP 47 codes likehe-IL:const rtl = ['ar', 'he', 'fa', 'ur'].includes(locale.split('-')[0]); document.documentElement.dir = rtl ? 'rtl' : 'ltr'; document.documentElement.lang = locale;Direction alone isn't enough: use CSS logical properties (
margin-inline-endrather thanmargin-right) or your layout will mirror incorrectly underdir="rtl". -
Date/number formatting. Use Angular's built-in
DatePipe/DecimalPipewith locale ID — works alongside ngx-translate, not in conflict with it.
Wrap up
Your Angular app supports 56 locales with runtime switching, lazy-loaded translation files, and CI-driven sync. Adding a locale is one change on the project in the dashboard (Projects → Edit → Locales) plus an <option> in the switcher.
Free tier: 5,000 words, counted once per target locale — so a small Angular app in three or four languages, not a whole corpus in all 56. Which plan do I need? works it out for your own string count.