Skip to content

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:

  • format decides your key shape. json-nested maps home.title to { "home": { "title": … } }; json-flat keeps "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 ProjectsEditLocalesUpdate 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 production out 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 no src/assets entry in angular.json. If you keep translations in src/assets/i18n/, add the mapping yourself or every locale fetch 404s at runtime:

    "assets": [
      {
        "glob": "**/*",
        "input": "src/assets",
        "output": "assets",
        "ignore": ["**/*.descriptions.json"]
      }
    ]
    

    The ignore entry matters: en-US.descriptions.json holds 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-root sits inside <body> — so set it imperatively when the locale changes, and match on the language subtag, since your locales are full BCP 47 codes like he-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-end rather than margin-right) or your layout will mirror incorrectly under dir="rtl".

  • Date/number formatting. Use Angular's built-in DatePipe/DecimalPipe with 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 (ProjectsEditLocales) 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.