Internationalization

Translate your app with Angular's @angular/localize. Mark text with i18n or $localize, extract it with ng extract-i18n, and load the right translation when the app starts. One bundle serves every language.

What's different on Lynx

  • Translations load at runtime. Angular's build-time translation, with one build per language, isn't supported. Your app calls loadTranslations() before it starts.
  • The language comes from the host app. AngularLynx reads appLocale from the host app's GlobalData. The iOS and Android apps that ng build --platform generates don't set it yet, so your app uses the sourceLocale from angular.json.
  • There's no Intl. $localize and Angular's pipes don't need it. Code that calls Intl directly needs a polyfill.
  • Switching language means restarting the app, for most text. Template i18n text and LOCALE_ID don't change after they're first used. Some strings can switch live.

Set up

ng generate @blotch/angular-lynx:add-i18n

This command:

  • installs @angular/localize, at the same version as @angular/core
  • adds "i18n": { "sourceLocale": "en-US" } to your project in angular.json
  • adds @angular/localize/init to the build's polyfills, so $localize exists before your code runs
  • adds @angular/localize to types in tsconfig.app.json, so TypeScript knows $localize
  • adds provideLocale() to your app config
src/app/app.config.ts
import {
  type ApplicationConfig,
  provideZonelessChangeDetection,
} from '@angular/core';
import {
  provideLocale,
  provideRenderer,
  provideRouter,
} from '@blotch/angular-lynx';
import { routes } from './app.routes';

export const appConfig: ApplicationConfig = {
  providers: [
    provideLocale(),
    provideZonelessChangeDetection(),
    provideRenderer(),
    provideRouter(routes),
  ],
};

provideLocale() sets Angular's LOCALE_ID from appLocale, falling back to sourceLocale, then to en-US.

Mark text for translation

src/app/greeting.ts
import { Component, computed, signal } from '@angular/core';
import { LYNX_ELEMENTS } from '@blotch/angular-lynx';

@Component({
  selector: 'app-greeting',
  imports: [LYNX_ELEMENTS],
  template: `
    <text i18n="@@greeting">Hello, world!</text>
    <text>{{ welcome() }}</text>
  `,
})
export class Greeting {
  readonly userName = signal('Ada');
  readonly welcome = computed(
    () => $localize`:@@welcome:Welcome, ${this.userName()}:userName:!`,
  );
}
  • i18n on an element marks its text for translation. @@greeting sets the message ID.
  • $localize marks a string in TypeScript. :@@welcome: sets its ID, and :userName: names the placeholder for ${this.userName()}.
  • Give every message an @@ ID. Translation files are keyed by ID, and a custom ID stays the same when you edit the English text.

Extract the messages

ng extract-i18n needs a target in angular.json, because Angular can't pick an extractor for the AngularLynx builder on its own. Add one under architect:

angular.json
"extract-i18n": {
  "builder": "@angular/build:extract-i18n",
  "options": {
    "format": "json",
    "outputPath": "src/locale"
  }
}

Then run it:

ng extract-i18n

It writes src/locale/messages.json with every message in your source language:

src/locale/messages.json
{
  "locale": "en-US",
  "translations": {
    "greeting": "Hello, world!",
    "welcome": "Welcome, {$userName}!"
  }
}

Copy it to one file per language, such as messages.fr.json, and translate the values. Keep placeholders like {$userName} as they are.

src/locale/messages.fr.json
{
  "locale": "fr",
  "translations": {
    "greeting": "Bonjour le monde !",
    "welcome": "Bienvenue, {$userName} !"
  }
}

The JSON format's translations object is exactly what loadTranslations() takes. Angular's default XLIFF format also works with translation tools, but you then convert each file to an object yourself.

Load a translation

Call loadTranslations() in main.ts, before bootstrapApplication():

src/main.ts
import { loadTranslations } from '@angular/localize';
import { bootstrapApplication, getLynxAppLocale } from '@blotch/angular-lynx';
import { App } from './app/app';
import { appConfig } from './app/app.config';
import fr from './locale/messages.fr.json';

if (getLynxAppLocale() === 'fr') {
  loadTranslations(fr.translations);
}

bootstrapApplication(App, appConfig);
  • getLynxAppLocale() reads appLocale without Angular's dependency injection, so it works before the app starts. It returns undefined when the host app hasn't set one.
  • Load before bootstrap. Angular translates template text and $localize strings when it creates each component, using whatever was loaded at that moment.
  • Messages without a translation show the source text and log a warning.

Read the current locale

Inject LynxLocale for the locale as a signal:

src/app/locale-badge.ts
import { Component, inject } from '@angular/core';
import { LYNX_ELEMENTS, LynxLocale } from '@blotch/angular-lynx';

@Component({
  selector: 'app-locale-badge',
  imports: [LYNX_ELEMENTS],
  template: `<text>Locale: {{ locale.locale() }}</text>`,
})
export class LocaleBadge {
  readonly locale = inject(LynxLocale);
}

locale() returns appLocale, or the same fallbacks as provideLocale(). Angular's pipes use LOCALE_ID instead. To format dates, numbers or currency for a locale other than en-US, register its data with registerLocaleData().

Switch language while the app runs

A $localize string inside a computed() can switch live. Make it depend on a signal, then replace the translations and change the signal:

src/app/language-picker.ts
import { Component, computed, signal } from '@angular/core';
import { clearTranslations, loadTranslations } from '@angular/localize';
import { LYNX_ELEMENTS } from '@blotch/angular-lynx';
import fr from '../locale/messages.fr.json';

@Component({
  selector: 'app-language-picker',
  imports: [LYNX_ELEMENTS],
  template: `
    <text>{{ greeting() }}</text>
    <view (bindtap)="use('fr')"><text>Français</text></view>
    <view (bindtap)="use('en')"><text>English</text></view>
  `,
})
export class LanguagePicker {
  readonly #language = signal('en');
  readonly greeting = computed(() => {
    this.#language(); // re-run when the language changes
    return $localize`:@@greeting:Hello, world!`;
  });

  use(language: string): void {
    clearTranslations();
    if (language === 'fr') {
      loadTranslations(fr.translations);
    }
    this.#language.set(language);
  }
}

Template i18n text, $localize strings outside a computed(), and LOCALE_ID keep the language the app started with. To change those, the app has to start again with the new appLocale.

Add an Intl polyfill

AngularLynx doesn't include an Intl polyfill. Without one, Number.prototype.toLocaleString() formats numbers the en-US way for every locale, and Intl.* calls throw.

If your code or a library needs Intl, install FormatJS's polyfills and list them in the build's polyfills, in this order:

angular.json
"polyfills": [
  "@angular/localize/init",
  "@formatjs/intl-getcanonicallocales/polyfill.js",
  "@formatjs/intl-locale/polyfill.js",
  "@formatjs/intl-pluralrules/polyfill.js",
  "@formatjs/intl-pluralrules/locale-data/en.js",
  "@formatjs/intl-numberformat/polyfill.js",
  "@formatjs/intl-numberformat/locale-data/en.js",
  "@formatjs/intl-datetimeformat/polyfill.js",
  "@formatjs/intl-datetimeformat/locale-data/en.js"
]

Add the locale-data files for each language you support. The build runs these files before your app on both threads. Check them on a device, because Lynx's JavaScript engine is older than a browser's (see The JavaScript environment).