Back to Blog
Angular DevelopmentJanuary 28, 20269 min read

Building Bilingual Angular Apps with ngx-translate

DR

Doug Ribeiro

9 min read

In Canadian banking, bilingual is not a feature request. It is a legal requirement. For a bank serving Quebec, the duty comes from Quebec's Charter of the French Language, which Bill 96 explicitly extended to federally regulated businesses operating in the province in 2022. Federally, Bill C-13 enacted the Use of French in Federally Regulated Private Businesses Act in 2023, but that Act is not in force yet. The Official Languages Act itself binds federal institutions, not banks, so there is no blanket OCOL mandate over every customer-facing screen. I shipped bilingual (EN/FR) features with ngx-translate on an Angular app at BMO. Having built in it, it is the approach I would pick again over Angular's built-in $localize. The code in this post is a generic setup for a current Angular codebase, not the bank's.

Why ngx-translate Over Angular's Built-In i18n

Angular's $localize requires a separate build per locale. That means two deployment artifacts, a more complex CI pipeline, and a full page reload to switch languages. ngx-translate loads translation files at runtime over HTTP and switches languages without reloading. For a banking portal where users expect to toggle language mid-session, that tradeoff is clear. The runtime cost of loading a JSON file is small at this size.

Setup

bash
npm install @ngx-translate/core @ngx-translate/http-loader
typescript
// app.config.ts (ngx-translate v17 provider API, unchanged in v18)
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({
      fallbackLang: 'en',
      loader: provideTranslateHttpLoader({
        prefix: './assets/i18n/',
        suffix: '.json'
      })
    })
  ]
};
provideAppInitializer TranslateHttpLoader en.json / fr.json TranslateService cache TranslatePipe / instant()

Translation File Structure

Flatten nothing. Nest by feature domain. A flat file with 300 keys is a maintenance nightmare when two people are editing it simultaneously.

json
// assets/i18n/en.json
{
  "common": {
    "save": "Save",
    "cancel": "Cancel",
    "loading": "Loading...",
    "error": "An error occurred. Please try again."
  },
  "accounts": {
    "title": "My Accounts",
    "balance": "Available Balance",
    "noTransactions": "No transactions found."
  },
  "lending": {
    "applyNow": "Apply Now",
    "creditLimit": "Credit Limit",
    "pendingReview": "Your application is under review."
  }
}
json
// assets/i18n/fr.json
{
  "common": {
    "save": "Enregistrer",
    "cancel": "Annuler",
    "loading": "Chargement...",
    "error": "Une erreur s'est produite. Veuillez réessayer."
  },
  "accounts": {
    "title": "Mes comptes",
    "balance": "Solde disponible",
    "noTransactions": "Aucune transaction trouvée."
  },
  "lending": {
    "applyNow": "Faire une demande",
    "creditLimit": "Limite de crédit",
    "pendingReview": "Votre demande est en cours d'examen."
  }
}

LanguageService with Signal State

I persist the preference in localStorage here (for signed-in users, a setting in the server-side profile follows them across devices) and update the html[lang] attribute on every switch. Screen readers use that attribute to select the correct voice profile. Missing this step means your French content gets read in an English voice.

typescript
import { DOCUMENT, inject, Injectable, signal } from '@angular/core';
import { TranslateService } from '@ngx-translate/core';

export type Language = 'en' | 'fr';

@Injectable({ providedIn: 'root' })
export class LanguageService {
  private translate = inject(TranslateService);
  private document = inject(DOCUMENT);

  currentLanguage = signal<Language>(
    (localStorage.getItem('lang') as Language) ?? 'en'
  );

  constructor() {
    this.translate.addLangs(['en', 'fr']);
    this.setLanguage(this.currentLanguage());
  }

  setLanguage(lang: Language): void {
    this.translate.use(lang);
    this.currentLanguage.set(lang);
    localStorage.setItem('lang', lang);
    this.document.documentElement.lang = lang;
  }
}

Using TranslatePipe in Templates

typescript
@Component({
  standalone: true,
  // TranslatePipe is standalone. TranslateModule still works on v17 but was removed in v18,
  // so import the pipe and the sample survives the upgrade.
  imports: [TranslatePipe, CurrencyPipe],
  template: `
    <h1>{{ 'accounts.title' | translate }}</h1>
    <p>{{ 'accounts.balance' | translate }}: {{ balance | currency:'CAD' }}</p>
    <!-- interpolation -->
    <p>{{ 'greeting' | translate:{ name: userName } }}</p>
  `
})
export class AccountsComponent {
  balance = 4250.00;
  userName = 'Marie';
}

Translating aria-labels in Component Code

The pipe only works in templates. For aria-label values bound from TypeScript, inject TranslateService. Do not wrap instant() in a getter and bind that getter in the template: a getter is a method call, and it re-runs on every change detection cycle. Resolve the string once, or bind a signal fed by stream(), which re-emits when the user switches language.

typescript
import { Component, inject } from '@angular/core';
import { toSignal } from '@angular/core/rxjs-interop';
import { TranslateService } from '@ngx-translate/core';

@Component({
  standalone: true,
  template: `<button [attr.aria-label]="closeLabel()">X</button>`
})
export class ModalComponent {
  private translate = inject(TranslateService);

  // stream() emits the current translation and re-emits on every language change,
  // so the label stays correct without a getter running on every change detection pass.
  readonly closeLabel = toSignal(this.translate.stream('common.closeModal'), {
    initialValue: ''
  });
}

Pluralization

Basic pluralization works with ICU message format, but that requires installing ngx-translate-messageformat-compiler. For most cases in English and French, a simpler pattern using separate keys is easier to maintain and easier for translators to work with.

json
// en.json, separate keys (no extra package)
{
  "transactions": {
    "none": "No transactions",
    "one": "1 transaction",
    "many": "{{count}} transactions"
  }
}

// ICU alternative (needs ngx-translate-messageformat-compiler)
{
  "transactionCount": "{count, plural, =0 {No transactions} =1 {1 transaction} other {# transactions}}"
}

The Layout Problem Nobody Warns You About

French strings are reliably 20-30% longer than their English equivalents. This means every fixed-width button, every truncated label, every ellipsis-capped table cell will look wrong in French. The classic way to learn this is a late QA round in French that reveals layout breaks across several pages at once. The fix is to test with French content from the first sprint, not the last.

  • Use min-width rather than fixed widths on buttons
  • Use text-overflow: ellipsis with a tooltip for truncated content, not hard cuts
  • Run your Storybook or component tests with French fixture data by default
  • Do not hardcode line lengths or wrapping assumptions in CSS
  • For mobile breakpoints, French content often requires one extra breakpoint than English

Preloading Translations for Faster Initial Render

By default, ngx-translate fetches translation files after the app bootstraps. Users on slow connections briefly see raw keys. Block the first render on that fetch with provideAppInitializer. APP_INITIALIZER has been deprecated since Angular 19 and the function form is less ceremony anyway: no factory, no deps array, inject() straight inside.

typescript
// app.config.ts
import { inject, provideAppInitializer } from '@angular/core';
import { TranslateService } from '@ngx-translate/core';
import { firstValueFrom } from 'rxjs';

// Goes in the same providers array as provideTranslateService from the setup block.
// It runs in an injection context, so inject() works directly.
provideAppInitializer(() => {
  const translate = inject(TranslateService);
  const lang = (localStorage.getItem('lang') as 'en' | 'fr') ?? 'en';
  translate.addLangs(['en', 'fr']);
  // The fallback is already configured as fallbackLang in provideTranslateService. To change it
  // at runtime, call setFallbackLang, which replaced setDefaultLang in v17.
  return firstValueFrom(translate.use(lang));
});

Bilingual is not a sprint at the end. Design your translation keys, your layout system, and your data contracts with both languages from week one.

References

Angulari18nngx-translateTypeScriptAccessibility