Developer Guides & Tutorials

Angular i18n: internationalization & localization with examples

Ilya Krukowski,Updated on January 28, 2026·26 min read
Angular and Lokalise logos

In this article, you will learn with examples how to get started with Angular i18n using the built-in internationalization module. We will cover the following topics:

  • Setting up the Angular application and configuring the built-in localize module.
  • Performing simple translations and providing additional translation data.
  • Extracting translations to XLF files using a special tool.
  • Working with pluralization and gender information.
  • Working with Angular pipes.
  • Performing translations within components.
  • Adding a language switcher.
  • Working with Angular routes.
  • Building the app using the AOT compiler and deploying to production.

For the purposes of this Angular i18n internationalization/localization tutorial, we'll be using version 21, which is the most recent version at the time of writing. Most of the concepts I'm going to show you are applicable for versions 9.1 and above. These updates include enhancements in translation and localization capabilities to make multi-language support more efficient.

Understanding software internationalization is essential for leveraging Angular’s built-in i18n features effectively, allowing your application to cater to a diverse global audience.

Preparing for Angular i18n

We'll require the Angular CLI, so be sure to install it by running:

npm install -g @angular/cli

Next, create a new Angular 21 application using the following command:

ng new i18n-angular-lokalise

Follow the installation wizard's instructions:

  • Stylesheet system to use (this is up to you really; I'm not planning to apply any styling so I'll stick with plain old CSS).
  • Use SSR/SSG — no, we're not going to discuss these topics
  • AI tools to use — none

Next, simply wait a couple of minutes and then make sure the app is starting without any issues:

cd i18n-angular-lokalise
ng serve --open

The ng serve --open command should open the application in your browser. After you've made sure everything is working, stop the server and install a localize package:

ng add @angular/localize

This is a new package introduced in Angular 9, which will add internationalization support to the app.

Next, we'll need to modify angular.json. I've pinpointed the relevant lines here:

{
  "$schema": "./node_modules/@angular/cli/lib/config/schema.json",
  "version": 1,
  "newProjectRoot": "projects",
  "projects": {
    "i18n-angular-lokalise": {
      "projectType": "application",
      // ...
      "i18n": { // <--- 1
        "sourceLocale": "en-US", // <--- 2
        "locales": { // <--- 3
          "lv": { // <--- 4
            "translation": "src/locale/messages.lv.xlf" // <--- 5
          }
        }
      },
      "architect": {
        "build": {
          "builder": "@angular/build:application",
          "options": {
            "localize": true, // <--- 6
            "i18nMissingTranslation": "error", // <--- 7
            // ...
          },
          "configurations": {
            // ...
          },
          "defaultConfiguration": "production"
        },
        // ...
      }
    }
  }
}

So, we are doing the following:

  1. Adding the i18n section. Be sure to add it under the project config.
  2. Setting the source language of the application to en-US (this is the default one, adjust as needed).
  3. Adding support for other locales.
  4. Specifically, I would like to translate my app into Latvian (the lv locale). Feel free to add any other languages as necessary.
  5. Translations for this language will live in the src/i18n/messages.lv.xlf file. Note that in the previous versions of Angular you would provide the --i18n-locale option when creating translations using command-line interface, but this option is no longer used.
  6. Set localize to true in order to generate application variants for each locale. We'll use the ahead-of-time compilation. Note that this applies to the production build only. The Angular development server supports one locale per build, so multi-locale localization is automatically disabled in dev mode and you'll see a warning about it.
  7. Report any missing translations upon building the app.

You can adjust other options as required and provide language-specific configuration as explained in the official documentation.

Now we can proceed to working with Angular internationalization examples!

Getting started with Angular internationalization

Marking the text for translation

In order to translate text, use the i18n attribute. Let's see it in action by replacing the src/app/app.html content with the following:

<h1 i18n>Hello world!</h1>

i18n is a special attribute that is recognized by the localize package. During the compilation it will be removed, and the tag content will be replaced with the proper translations.

This attribute may contain translation metadata such as this:

<h1 i18n="Friendly welcoming message">Hello world!</h1>

Moreover, it is possible to provide the intended meaning of the translation. Just separate the meaning and description with a pipe | character, like so:

<h1 i18n="main header|Friendly welcoming message">Hello world!</h1>

This additional data provides context for your translators. Specifically, you may also explain on what pages the translation will be displayed, what tone should be used, and so on.

Creating translation files

Now, where and how do we store translations for Angular i18n? Usually, they live in the src/i18n or src/locale folder. As for the translation format, there are multiple options:

  • XLIFF 1.2 (default)
  • XLIFF 2
  • XML message bundle (XMB)
  • JSON
  • ARB

Let's stick with the default option, but the next question is: How do we actually create translation files? Should we do it manually? No! There is a special command-line tool called extract-i18n, which does the heavy lifting for us and extracts all the translations to a separate file.

Extracting translations

Simply run the following command to create a new translation file under the src/locale folder:

ng extract-i18n --output-path src/locale

In the src/locale, you will find a messages.xlf file in XLIFF 1.2 format. This is the base translation file with the following contents:

<?xml version="1.0" encoding="UTF-8" ?>
<xliff version="1.2" xmlns="urn:oasis:names:tc:xliff:document:1.2">
  <file source-language="en-US" datatype="plaintext" original="ng2.template">
    <body>
      <trans-unit id="2241399650733502220" datatype="html">
        <source>Hello world!</source>
        <context-group purpose="location">
          <context context-type="sourcefile">src/app/app.html</context>
          <context context-type="linenumber">1</context>
        </context-group>
        <note priority="1" from="description">Friendly welcoming message</note>
        <note priority="1" from="meaning">main header</note>
      </trans-unit>
    </body>
  </file>
</xliff>
  • trans-unit is the tag containing a single translation; id is a translation identifier and has a special meaning — extract-i18n generates the id for us so do not modify it here! We will discuss this attribute later in more detail.
  • source contains translation source text.
  • context-group specifies where exactly the given translation can be found.
  • context-type="sourcefile" shows the file where the translation lives.
  • context-type="linenumber" shows the actual line of code.
  • Also, there are two note tags that provide the translation description and meaning, respectively.

You can learn more about XLIFF files in general and how to translate them in our tutorial.

Next, you can copy the messages.xlf file and name your copy messages.lv.xlf. This new file is going to store Latvian translations. In order to translate something, add a target tag immediately after the source in the trans-unit as follows:

<?xml version="1.0" encoding="UTF-8" ?>
<xliff version="1.2" xmlns="urn:oasis:names:tc:xliff:document:1.2">
  <file source-language="en-US" datatype="plaintext" original="ng2.template">
    <body>
      <trans-unit id="2241399650733502220" datatype="html">
        <source>Hello world!</source>
        <target>Sveika, pasaule!</target>
        <context-group purpose="location">
          <context context-type="sourcefile">src/app/app.html</context>
          <context context-type="linenumber">1</context>
        </context-group>
        <note priority="1" from="description">Friendly welcoming message</note>
        <note priority="1" from="meaning">main header</note>
      </trans-unit>
    </body>
  </file>
</xliff>

Also, it is a good idea to provide the target-language attribute for the file tag so that translation management systems can detect the locale properly – see below:

<?xml version="1.0" encoding="UTF-8" ?>
<xliff version="1.2" xmlns="urn:oasis:names:tc:xliff:document:1.2">
  <file source-language="en-US" datatype="plaintext" original="ng2.template" target-language="lv">
    <!-- ... -->
  </file>
</xliff>

After performing these changes, let's check that everything is translated properly. We'll tweak the configuration within the angular.json file:

"build": {
    // ...
    "configurations": {
      // ...
      "lv": {
        "localize": ["lv"] // <--- 1
      }
    },
    // ...
  },
  "serve": {
    "builder": "@angular-devkit/build-angular:dev-server",
    "configurations": {
      // ...
      "lv": {
        "buildTarget": "i18n-angular-lokalise:build:development,lv" // <--- 2
      }
    },
    // ...
  },
  // ...
}

Here are two relevant lines:

  1. We're adding a new configuration specifically for the lv locale.
  2. This configuration should be available when serving the app in development.

Now we can easily use the ng serve command and instruct Angular to show us the Latvian version of the app:

ng serve --configuration=lv --open

Ensure that the translation is displayed properly, and proceed to the next section.

Translation identifiers

I've already mentioned that translation IDs (the id attribute for the trans-unit tag) have special meanings. These IDs are unique and extract-i18n generates them based on the combination of the source text and its meaning. Therefore, whenever you update the translation source or its meaning, the ID will change. For instance, let's modify our translation text as follows:

<h1 i18n="main header|Friendly welcoming message">Hello everyone!</h1>

Take a note of the current translation ID (it's 2241399650733502220 in my case). Then regenerate base translation file:

ng extract-i18n --output-path src/locale

Now src/i18n/messages.xlf contains the following:

<?xml version="1.0" encoding="UTF-8" ?>
<xliff version="1.2" xmlns="urn:oasis:names:tc:xliff:document:1.2">
  <file source-language="en-US" datatype="plaintext" original="ng2.template">
    <body>
      <trans-unit id="1970068439210080997" datatype="html">
        <source>Hello everyone!</source>
        <context-group purpose="location">
          <context context-type="sourcefile">src/app/app.html</context>
          <context context-type="linenumber">1</context>
        </context-group>
        <note priority="1" from="description">Friendly welcoming message</note>
        <note priority="1" from="meaning">main header</note>
      </trans-unit>
    </body>
  </file>
</xliff>

The ID has changed! Try starting the application again:

ng serve --configuration=lv --open

You'll see the below error message in the console because the translation ID has changed:

No translation found for "1970068439210080997" ("Hello everyone!" - "main header").

To fix this, update the id attribute and the source tag in the src/i18n/messages.lv.xlf file, like so:

<?xml version="1.0" encoding="UTF-8" ?>
<xliff version="1.2" xmlns="urn:oasis:names:tc:xliff:document:1.2">
  <file source-language="en-US" datatype="plaintext" original="ng2.template" target-language="lv">
    <body>
      <trans-unit id="1970068439210080997" datatype="html">
        <!-- ... -->
      </trans-unit>
    </body>
  </file>
</xliff>

Note that if you have multiple occurrences of the same source text and meaning, all of them will have the same translation. This is due to the fact that their identifiers are similar.

Custom translation identifiers

So, having auto-generated translation IDs is not very convenient because they depend on the source text and the meaning. However, it is possible to provide custom identifiers using the @@ prefix – for example:

<h1 i18n="main header|Friendly welcoming message@@welcome">Hello everyone!</h1>

Rerun the extract-i18n command, and take a look at the base translation file:

<?xml version="1.0" encoding="UTF-8" ?>
<xliff version="1.2" xmlns="urn:oasis:names:tc:xliff:document:1.2">
  <file source-language="en-US" datatype="plaintext" original="ng2.template">
    <body>
      <trans-unit id="welcome" datatype="html">
        <!-- ... -->
      </trans-unit>
    </body>
  </file>
</xliff>

The ID is now set to welcome, and it will not change even if you modify the source text. Don't forget to provide a new ID in the messages.lv.xlf file and adjust translation accordingly:

<?xml version="1.0" encoding="UTF-8" ?>
<xliff version="1.2" xmlns="urn:oasis:names:tc:xliff:document:1.2">
  <file source-language="en-US" datatype="plaintext" original="ng2.template" target-language="lv">
    <body>
      <trans-unit id="welcome" datatype="html">
        <source>Hello everyone!</source>
        <target>Sveiki visiem!</target>
        
        <!-- ... -->
      </trans-unit>
    </body>
  </file>
</xliff>

Be sure to assign unique custom identifiers for different translations! If you provide the same IDs, only the first translation will be extracted:

<h1 i18n="main header|Friendly welcoming message@@welcome">Hello everyone!</h1>

<!-- "Other text" will be ignored. Translation for "Hello everyone!" will be used here instead: -->
<p i18n="@@welcome">Other text</p>

Feel free to check the Translation keys: naming conventions and organizing article to learn more on keys organization.

Angular internationalization use cases

Translating attributes

The Angular i18n module allows us to translate both tag content and attributes. Suppose there is a link to your portfolio in the src/app/app.component.html:

<p>
  <a i18n href="/en-US/portfolio" title="My Portfolio">Portfolio</a>
</p>

The link text will be translated properly, but what about the title and href? To deal with these attributes, provide the i18n-ATTRIBUTE_NAME attributes in the following way:

<p>
  <a i18n i18n-href i18n-title href="/en-US/portfolio" title="My Portfolio">Portfolio</a>
</p>

Perform translation extraction as normal by running:

ng extract-i18n --output-path src/locale

Copy three new trans-unit tags from the src/i18n/messages.xlf file into the src/i18n/messages.lv.xlf file and provide target, like so:

<trans-unit id="8336405567663867706" datatype="html">
  <source>/en-US/portfolio</source>
  <target>/lv/portfolio</target>
  <context-group purpose="location">
    <context context-type="sourcefile">src/app/app.html</context>
    <context context-type="linenumber">4</context>
  </context-group>
</trans-unit>
<trans-unit id="6709320430327029754" datatype="html">
  <source>My Portfolio</source>
  <target>Mans portfelis</target>
  <context-group purpose="location">
    <context context-type="sourcefile">src/app/app.html</context>
    <context context-type="linenumber">4</context>
  </context-group>
</trans-unit>
<trans-unit id="9201103587777813545" datatype="html">
  <source>Portfolio</source>
  <target>Portfelis</target>
  <context-group purpose="location">
    <context context-type="sourcefile">src/app/app.html</context>
    <context context-type="linenumber">4,5</context>
  </context-group>
</trans-unit>

We have translated the link text, the URL, and the title!

Performing pluralization

We'll continue our Angular localization tutorial by discussing pluralization. Imagine that we'd like to show how many ongoing tasks the user has. The built-in i18n module utilizes an ICU message format which may seem a bit complex at first:

<span i18n>
  {tasksCount, plural, 
    zero {no tasks} 
    one {one task} 
    other {{{tasksCount}} tasks}
  }
</span>
  • tasksCount is the variable that we'll create in a moment.
  • plural is the name of an ICU expression.
  • zero provides text when there are no tasks.
  • one contains text when there is 1 task.
  • other covers all other cases.

Don't forget to create a new variable in the src/app/app.ts file as below:

// ...
export class App {
  tasksCount = 3;
}

Extract the new translations and update the src/i18n/messages.lv.xlf file as follows:

<trans-unit id="2380939390102386148" datatype="html">
  <source> <x id="ICU" equiv-text="{tasksCount, plural, 
      zero {no tasks} 
      one {one task} 
      other {{{tasksCount}} tasks}
    }" xid="9057336704109090456"/>
  </source>
  <context-group purpose="location">
    <context context-type="sourcefile">src/app/app.html</context>
    <context context-type="linenumber">8,13</context>
  </context-group>
</trans-unit>
<trans-unit id="324706692010051697" datatype="html">
  <source>{VAR_PLURAL, plural, zero {no tasks} one {one task} other {<x id="INTERPOLATION"/> tasks}}</source>
  <target>{VAR_PLURAL, plural, 
    zero {nav uzdevumu} 
    one {viens uzdevums} 
    other {<x id="INTERPOLATION"/> uzdevumi}
  }</target>
  <context-group purpose="location">
    <context context-type="sourcefile">src/app/app.html</context>
    <context context-type="linenumber">8,11</context>
  </context-group>
</trans-unit>

Choosing translation with select

Another useful ICU expression is select. It allows you to choose one of the translations based on a value. For example, it is very helpful when working with gender information:

<span i18n>
  Gender: {genderCode, select,
    0 {male}
    1 {female}
    other {other answer}
  }
</span>

Based on the value of the genderCode, we will display either "male", "female", or "other answer".

Now let's add a genderCode variable and provide the ability to switch gender inside the src/app/app.ts file in the following way:

export class App {
  tasksCount = 3;
  genderCode = 0;

  male() { this.genderCode = 0; }
  female() { this.genderCode = 1; }
  other() { this.genderCode = 2; }
}

Next, extract the translations and update the src/i18n/messages.lv.xlf file:

<trans-unit id="5485045432871737355" datatype="html">
  <source> Gender: <x id="ICU" equiv-text="{genderCode, select,
      0 {male}
      1 {female}
      other {other answer}
    }" xid="942937716535918063"/>
  </source>
  <target> Dzimums: <x id="ICU" equiv-text="{genderCode, select,
      0 {male}
      1 {female}
      other {other answer}
    }" xid="942937716535918063"/>
  </target>
  <context-group purpose="location">
    <context context-type="sourcefile">src/app/app.html</context>
    <context context-type="linenumber">16,21</context>
  </context-group>
</trans-unit>
<trans-unit id="942937716535918063" datatype="html">
  <source>{VAR_SELECT, select, 0 {male} 1 {female} other {other answer}}</source>
  <target>{VAR_SELECT, select,
    0 {vīrietis}
    1 {sieviete}
    other {cita atbilde}
  }</target>
  <context-group purpose="location">
    <context context-type="sourcefile">src/app/app.html</context>
    <context context-type="linenumber">16,19</context>
  </context-group>
</trans-unit>

Note that there are two translations: One for the "Gender:" part and another for the actual select expression.

Lastly, display three buttons to choose gender within the app.html file:

<button (click)="male()">♂</button>
<button (click)="female()">♀</button>
<button (click)="other()">⚧</button>

Once a button is clicked, the message with the gender info will be updated instantly.

Translation without a tag

All the examples we've seen previously required some sort of tag. Sometimes, you may need to translate plain text without rendering any tags at all. Angular allows this by applying the i18n attribute to a structural wrapper such as ng-container:

<ng-container i18n>Copyright 2026</ng-container>

Upon page display, ng-container will be gone, and you'll have simple plain text. Extract the text and provide the corresponding Latvian translation as usual:

<trans-unit id="4005682646458942593" datatype="html">
  <source>Copyright 2026</source>
  <target>Autortiesības 2026</target>
  <context-group purpose="location">
    <context context-type="sourcefile">src/app/app.html</context>
    <context context-type="linenumber">27</context>
  </context-group>
</trans-unit>

Using Angular pipes

The built-in i18n module plays nicely with common pipes used for localization in Angular: DatePipe, CurrencyPipe, DecimalPipe, and PercentPipe.

Now the locale data are loaded and we can use the pipes mentioned above. For instance, let's perform date localization:

<p>{{today | date:'fullDate'}}</p>

Create a new today variable in the src/app/app.ts file and make sure to properly import the DatePipe class:

import { DatePipe } from '@angular/common';
// ...

@Component({
  selector: 'app-root',
  imports: [
    DatePipe,
    // ...
  ],
  templateUrl: './app.html',
  styleUrl: './app.css'
})
export class App {
  today: number = Date.now();
  // ...
}

Upon running the Latvian version of the app, you will see the localized date!

Translations within components

Sometimes you may need to localize strings directly inside your component class. Angular supports this through the $localize() tagged template function.

For example, add the following lines to src/app/app.ts:

// ...
export class App {
  company = "Lokalise";
  created_by = $localize`Created by ${this.company}`;
}

And use the variable inside your template:

<p>{{ created_by }}</p>

Now run the extractor again — it should detect the newly added content properly.

Add Latvian the translation to the src/i18n/messages.lv.xlf file. Don't forget to provide placeholders, for instance:

<trans-unit id="3990133897753911565" datatype="html">
  <source>Created by <x id="PH" equiv-text="this.company"/></source>
  <target>Izveidojis <x id="PH" equiv-text="this.company"/></target>
  <context-group purpose="location">
    <context context-type="sourcefile">src/app/app.ts</context>
    <context context-type="linenumber">18</context>
  </context-group>
</trans-unit>

Now the created_by variable has the correct translation!

$localize also supports providing a custom meaning, description, and translation ID. The format follows the same pattern as template-level i18n: meaning|description@@id, wrapped between colons at the start of the message.

created_by = $localize`:used on the main page|explains who created the app@@created_by:
  Created by ${this.company}
`;

The metadata inside the colons is used by Angular’s localization system and will appear in the extracted translation file.

Adding a language switcher

The next thing I would like to show you in this Angular internationalization tutorial is how to create a simple language switcher.

First, add a new localesList variable to the src/app/app.ts file in the following way:

// ...
export class App {
  localesList = [
    { code: 'en-US', label: 'English' },
    { code: 'lv', label: 'Latviešu' }
  ];

  // ...
}

Make sure to set the proper language codes.

Then, you just need to add an unordered list to the src/app/app.html:

<ul>
  @for (locale of localesList; track locale.code) {
    <li>
      <a href="/{{ locale.code }}/">{{ locale.label }}</a>
    </li>
  }
</ul>

Deploying the app

So, we have finished building our Angular i18n sample app, and now it is time to share our work with the world!

Preparing for deployment

In this section, I will show you how to deploy to Firebase. Therefore, you'll need to create a new account there to get started.

Then, install Firebase tools:

npm install -g firebase-tools

Log in to Firebase locally using the following command:

firebase login

Performing the deployment

At this point, we're ready to get rolling. Compile your application with an ahead-of-time compiler (which is the preferred method):

ng build

This will create both English and Latvian versions of the app under the dist directory in one go. This is very convenient because in previous Angular versions we had to build each application separately and this took significantly more time. It still possible to provide a specific version of the app to build, like so:

ng build --configuration=production,lv

Once the build is finished, let's add a special package for Firebase:

ng add @angular/fire

Once the package is installed, you'll need to answer a couple of questions:

  • Features to use: choose "hosting"
  • What Firebase account to use
  • Which project and website to use (you'll have an option to create a new Firebase project)

Having done that, run the following command:

ng deploy

After a few moments your site will be up and running!

Problem is, at the time of writing the @angular/fire package is not yet fully compatible with Angular 21. If you're experiencing a similar issue, you can either try installing a release candidate:

npm i firebase @angular/fire@21.0.0-rc.0

Or simply use Firebase tools to create a new project (or create one manually). Here's the sample firebase.json config:

{
  "hosting": {
    "public": "dist/i18n-angular-lokalise/browser",
    "ignore": [
      "firebase.json",
      "**/.*",
      "**/node_modules/**"
    ],
    "headers": [
      {
        "source": "**/*.js",
        "headers": [
          {
            "key": "Cache-Control",
            "value": "public,max-age=31536000,immutable"
          }
        ]
      },
      {
        "source": "**/*.css",
        "headers": [
          {
            "key": "Cache-Control",
            "value": "public,max-age=31536000,immutable"
          }
        ]
      },
      {
        "source": "**/index.html",
        "headers": [
          {
            "key": "Cache-Control",
            "value": "no-cache"
          }
        ]
      },
      {
        "source": "/@(ngsw-worker.js|ngsw.json)",
        "headers": [
          {
            "key": "Cache-Control",
            "value": "no-cache"
          }
        ]
      }
    ],
    "rewrites": [
      {
        "source": "/en-US",
        "destination": "/en-US/index.html"
      },
      {
        "source": "/en-US/**",
        "destination": "/en-US/index.html"
      },
      {
        "source": "/lv",
        "destination": "/lv/index.html"
      },
      {
        "source": "/lv/**",
        "destination": "/lv/index.html"
      }
    ],
    "redirects": [
      {
        "source": "/",
        "destination": "/en-US",
        "type": 301
      }
    ]
  }
}

And .firebaserc:

{
  "projects": {
    "default": "lokalise-angular21-i18n-demo"
  }
}

Inside, provide your Firebase project name.

With this configuration in place, you can build and deploy:

ng build
firebase deploy

Now you can browse your application. In my case, there are two URLs:

Making it work with Angular Routing

At this point your application should work fine on Firebase. However, if you are going to implement a routing system, additional steps have to be taken.

Creating components and routes

Let's create two simple routes – /home and /about – that should utilize HomeComponent and AboutComponent, respectively. Start by creating these two components:

ng generate component home
ng generate component about

Next, open app.routes.ts and provide the actual routes:

import { Routes } from '@angular/router';

export const routes: Routes = [
  {
    path: 'home',
    loadComponent: () => import('./home/home').then(m => m.Home),
  },
  {
    path: 'about',
    loadComponent: () => import('./about/about').then(m => m.About),
  },
  { path: '', pathMatch: 'full', redirectTo: 'home' },
];

So, we have added handlers for the /home and /about paths, and we also have provided a redirect to open the "home" page by default.

Adjusting components

Now, open up app.html, and move everything except for the language switcher to the home/home.html file:

<h2 i18n="main header|Friendly welcoming message@@welcome">Hello everyone!</h2>

<p>
  <a i18n i18n-href i18n-title href="/en-US/portfolio" title="My Portfolio">Portfolio</a>
</p>

<span i18n>
  {tasksCount, plural, 
    zero {no tasks} 
    one {one task} 
    other {{{tasksCount}} tasks}
  }
</span>

<span i18n>
  Gender: {genderCode, select,
    0 {male}
    1 {female}
    other {other answer}
  }
</span>

<button (click)="male()">♂</button>
<button (click)="female()">♀</button>
<button (click)="other()">⚧</button>

<p>{{today | date:'fullDate'}}</p>

<p>{{ created_by }}</p>

<ng-container i18n>Copyright 2026</ng-container>

Tweak your app.html so that it looks like this:

<ul>
  @for (locale of localesList; track locale.code) {
    <li>
      @if (locale.code === currentLocale) {
        <span>{{ locale.label }}</span>
      } @else {
        <a href="/{{ locale.code }}">{{ locale.label }}</a>
      }
    </li>
  }
</ul>

<nav>
  <a class="button" routerLink="/home" i18n>Home</a> |
  <a class="button" routerLink="/about" i18n>About</a>
</nav>

<p>{{currentLocale}}</p>
<h1 i18n>Main page</h1>

<router-outlet />

As you can see, I've adjusted the language switcher so that it does not display a link for the currently chosen locale. We can detect the current locale easily inside app.ts by using LOCALE_ID. Also let's include the RouterLink properly:

// app.ts
import { Component, signal, LOCALE_ID, inject } from '@angular/core';
import { RouterOutlet, RouterLink } from '@angular/router';

@Component({
  selector: 'app-root',
  imports: [RouterOutlet, RouterLink],
  templateUrl: './app.html',
  styleUrl: './app.css'
})
export class App {
  localesList = [
    { code: 'en-US', label: 'English' },
    { code: 'lv', label: 'Latviešu' }
  ];
  protected readonly title = signal('i18n-angular-lokalise');
  currentLocale = inject(LOCALE_ID);
}

Move all other code from the app.ts file to the home/home.ts file:

// home/home.ts
import { Component } from '@angular/core';
import { DatePipe } from '@angular/common';

@Component({
  selector: 'app-home',
  imports: [DatePipe],
  templateUrl: './home.html',
  styleUrl: './home.css',
})
export class Home {
  tasksCount = 2;
  genderCode = 0;
  today: number = Date.now();

  company = "Lokalise";
  created_by = $localize`Created by ${this.company}`;

  male() { this.genderCode = 0; }
  female() { this.genderCode = 1; }
  other() { this.genderCode = 2; }
}

Replace the contents inside about/about.html with a header:

<h2 i18n>About Us</h2>

Translating the new pages

We have added some new HTML tags, so let's extract the texts into our translation files:

ng extract-i18n --output-path src/locale

Now, copy the generated XML tags from messages.xlf, paste them into the messages.lv.xlf file, and adjust your translations:

<?xml version="1.0" encoding="UTF-8" ?>
<xliff version="1.2" xmlns="urn:oasis:names:tc:xliff:document:1.2">
  <file source-language="en-US" datatype="plaintext" original="ng2.template" target-language="lv">
    <body>
      <trans-unit id="5736481176444461852" datatype="html">
        <source>About Us</source>
        <context-group purpose="location">
          <context context-type="sourcefile">src/app/about/about.html</context>
          <context context-type="linenumber">1</context>
        </context-group>
      </trans-unit>
      <trans-unit id="2821179408673282599" datatype="html">
        <source>Home</source>
        <target>Sākums</target>
        <context-group purpose="location">
          <context context-type="sourcefile">src/app/app.html</context>
          <context context-type="linenumber">10,11</context>
        </context-group>
      </trans-unit>
      <trans-unit id="1726363342938046830" datatype="html">
        <source>About</source>
        <target>Par mums</target>
        <context-group purpose="location">
          <context context-type="sourcefile">src/app/app.html</context>
          <context context-type="linenumber">11,12</context>
        </context-group>
      </trans-unit>
      <trans-unit id="326281569793279925" datatype="html">
        <source>Main page</source>
        <target>Galvenā lapa</target>
        <context-group purpose="location">
          <context context-type="sourcefile">src/app/app.html</context>
          <context context-type="linenumber">14,16</context>
        </context-group>
      </trans-unit>
      <trans-unit id="welcome" datatype="html">
        <source>Hello everyone!</source>
        <target>Sveiki visiem!</target>
        <context-group purpose="location">
          <context context-type="sourcefile">src/app/home/home.html</context>
          <context context-type="linenumber">1,5</context>
        </context-group>
        <note priority="1" from="description">Friendly welcoming message</note>
        <note priority="1" from="meaning">main header</note>
      </trans-unit>
      <trans-unit id="8336405567663867706" datatype="html">
        <source>/en-US/portfolio</source>
        <target>/lv/portfolio</target>
        <context-group purpose="location">
          <context context-type="sourcefile">src/app/home/home.html</context>
          <context context-type="linenumber">5</context>
        </context-group>
      </trans-unit>
      <trans-unit id="6709320430327029754" datatype="html">
        <source>My Portfolio</source>
        <target>Mans portfelis</target>
        <context-group purpose="location">
          <context context-type="sourcefile">src/app/home/home.html</context>
          <context context-type="linenumber">5</context>
        </context-group>
      </trans-unit>
      <trans-unit id="9201103587777813545" datatype="html">
        <source>Portfolio</source>
        <target>Portfelis</target>
        <context-group purpose="location">
          <context context-type="sourcefile">src/app/home/home.html</context>
          <context context-type="linenumber">5,6</context>
        </context-group>
      </trans-unit>
      <trans-unit id="2380939390102386148" datatype="html">
        <source> <x id="ICU" equiv-text="{tasksCount, plural, 
    zero {no tasks} 
    one {one task} 
    other {{{tasksCount}} tasks}
  }" xid="9057336704109090456"/>
</source>
        <context-group purpose="location">
          <context context-type="sourcefile">src/app/home/home.html</context>
          <context context-type="linenumber">9,17</context>
        </context-group>
      </trans-unit>
      <trans-unit id="324706692010051697" datatype="html">
        <source>{VAR_PLURAL, plural, zero {no tasks} one {one task} other {<x id="INTERPOLATION"/> tasks}}</source>
        <target>{VAR_PLURAL, plural, 
          zero {nav uzdevumu} 
          one {viens uzdevums} 
          other {<x id="INTERPOLATION"/> uzdevumi}
        }</target>
        <context-group purpose="location">
          <context context-type="sourcefile">src/app/home/home.html</context>
          <context context-type="linenumber">9,12</context>
        </context-group>
      </trans-unit>
      <trans-unit id="5485045432871737355" datatype="html">
        <source> Gender: <x id="ICU" equiv-text="{genderCode, select,
    0 {male}
    1 {female}
    other {other answer}
  }" xid="942937716535918063"/>
</source>
        <target> Dzimums: <x id="ICU" equiv-text="{genderCode, select,
            0 {male}
            1 {female}
            other {other answer}
          }" xid="942937716535918063"/>
        </target>
        <context-group purpose="location">
          <context context-type="sourcefile">src/app/home/home.html</context>
          <context context-type="linenumber">17,24</context>
        </context-group>
      </trans-unit>
      <trans-unit id="942937716535918063" datatype="html">
        <source>{VAR_SELECT, select, 0 {male} 1 {female} other {other answer}}</source>
        <target>{VAR_SELECT, select,
          0 {vīrietis}
          1 {sieviete}
          other {cita atbilde}
        }</target>
        <context-group purpose="location">
          <context context-type="sourcefile">src/app/home/home.html</context>
          <context context-type="linenumber">17,20</context>
        </context-group>
      </trans-unit>
      <trans-unit id="4005682646458942593" datatype="html">
        <source>Copyright 2026</source>
        <target>Autortiesības 2026</target>
        <context-group purpose="location">
          <context context-type="sourcefile">src/app/home/home.html</context>
          <context context-type="linenumber">32</context>
        </context-group>
      </trans-unit>
      <trans-unit id="3990133897753911565" datatype="html">
        <source>Created by <x id="PH" equiv-text="this.company"/></source>
        <target>Izveidojis <x id="PH" equiv-text="this.company"/></target>
        <context-group purpose="location">
          <context context-type="sourcefile">src/app/home/home.ts</context>
          <context context-type="linenumber">16</context>
        </context-group>
      </trans-unit>
    </body>
  </file>
</xliff>

That's it!

Configuring Firebase

The most important step here is configuring Firebase properly. As long as we're using the AOT compiler, Firebase will effectively serve two different applications (for the Latvian and English locales). Thus, to make Firebase play nicely with our Angular routes, we should adjust the rewrite rules. To achieve this, open firebase.json and make sure you have the following lines:

{
  "hosting": {
    "rewrites": [
      {
        "source": "/en-US",
        "destination": "/en-US/index.html"
      },
      {
        "source": "/en-US/**",
        "destination": "/en-US/index.html"
      },
      {
        "source": "/lv",
        "destination": "/lv/index.html"
      },
      {
        "source": "/lv/**",
        "destination": "/lv/index.html"
      }
    ]
  }
}

Also check that you have proper redirects:

{
  "hosting": {
    "redirects": [
      {
        "source": "/",
        "destination": "/en-US",
        "type": 301
      }
    ]
  }
}

This is the final version of our firebase.json config file.

Deploying once again

We have now dealt with all the Firebase issues, so you can build and deploy your application again:

ng build
ng deploy

Great job!

Managing translations at scale with Lokalise

By now we’ve successfully implemented Angular’s built-in i18n and even deployed a localized multi-build app. Everything works, but you’ve probably noticed one thing already: even with a small set of strings, translation files get messy fast. XLIFF is powerful, but it’s also:

  • full of tags and placeholders that non-technical translators may struggle with, and a single misplaced tag can break the entire string,
  • easy to damage accidentally, since deleting or moving even a tiny piece of markup often leads to invalid or unusable translations,
  • difficult to maintain once multiple languages and translators join the workflow, especially when everyone edits raw XML files independently,
  • and still limited in terms of real-world context. XLIFF notes (description, meaning) help, but sometimes translators need to see where the text appears (screenshots, UI previews) or ask quick clarifying questions, which is much easier to handle inside a TMS with built-in comments/chat.

This becomes a real problem as soon as you want to collaborate with non-technical translators or use AI translation tools that rely heavily on context. This is where a translation management system (TMS) becomes essential, and Lokalise fits naturally into this workflow.

Why use a TMS instead of translating XLIFF by hand:

  • More context. You can attach screenshots or short notes so translators see where the text is actually used.
  • Consistent terminology. All product-specific terms stay in one place, so translations don’t drift.
  • Consistent style. You can define the tone once (formal, casual, whatever) and keep it the same across languages.
  • AI with project knowledge. Translations can be based on your existing content, glossary, style guide, or translation memory — not random guesses done in isolation.
  • Easy communication. Translators can ask questions or leave comments directly on a key instead of guessing what you meant.
  • No file juggling. You don’t have to import or export XLIFF manually. Lokalise introduces various tools to help you configure complex automated workflows easily.

Setting up Lokalise for Angular i18n

  1. Create a Lokalise account. Register for free and test drive all Lokalise features for 14 days. No credit card needed, and later you can stay on the Free plan for as long as you wish.
  2. Generate an API token. Go to Personal profile > API tokens and create a read-write token. We'll need this token later.
  3. Create a new project:
    1. Click New project
    2. Choose Web and mobile
    3. Give it a name
    4. Set the base language (must match your Angular source locale, e.g., en-US)
    5. Add target languages: pick any extra locales you need (e.g., Latvian, French, German). I'm going to select Latvian and French (fr). Yes, we haven't yet introduced support for the fr locale but it's not a problem: we'll employ AI for translation.
    6. Copy your project ID. Open More > Settings and make a note of it.

At this point Lokalise is set up, and we’re ready to upload the XLIFF files generated by Angular (src/locale/messages.xlf, messages.lv.xlf, etc.).

Configuring automations for AI-powered translations

Now let’s set up a simple automation in Lokalise that will auto-translate updated keys using AI. To achieve that, open your Lokalise project, click More > Automations, and then press Create. You'll see the following interface:

2026-01-26 16_12_17-Angular i18n _ Lokalise — LibreWolf.webp

Let's cover the most important settings:

  • Monitored language — choose your main source language (in our case, English).
  • Automated languages — select the languages you want to translate into (for example, Latvian and French).
  • Actions — pick Use Lokalise Pro AI.
  • Enable Apply key descriptions as translation context so the AI can use the notes you added in XLIFF.

Save the automation — that’s it! Now whenever English strings change, Lokalise will automatically generate translations for the selected languages.

A couple of notes:

  • By default, any small update triggers the automation. If you want AI to react only to bigger edits, adjust Minimal change required.
  • AI doesn’t overwrite existing translations unless you explicitly allow it. To force updates for non-empty translations, enable Force this action.

Using GitHub Actions to push translations files

Sure, we could upload the translation files to Lokalise manually through the UI, and that works perfectly fine. But it’s not very exciting, and if you prefer a more advanced setup that can be automated later, there are better options. Lokalise offers several ways to sync translations: you can hit the API directly, use the CLI tool, rely on SDKs for various languages, connect integrations for GitHub or GitLab (and other services), or even use framework-specific solutions that handle file exchange automatically (we currently have ready-made setups for Ruby on Rails and JavaScript apps). In other words, you’re not short on choices.

In this tutorial, though, I want to highlight another approach I recently prepared: GitHub Actions for sending and pulling translation files. These actions are available on the GitHub Marketplace, they’re open source, and they work with basically any framework (unless you have some very unusual, highly customized setup). First of all, let's see how to configure the "push" action that is going to upload our XLIFF files to Lokalise.

Notes on XLF files

As you already know, Angular stores the "base" translations in a file called messages.xlf. This works fine for Angular, but it’s not ideal when you start using a TMS; having one file without a locale code makes things harder to distinguish later. A simple fix is to extract the base locale into a properly named file:

ng extract-i18n --output-path src/locale --out-file messages.en-US.xlf

This recreates your English source messages inside messages.en-US.xlf. You can keep messages.xlf if you like, or remove it once your workflow no longer relies on it.

Another thing worth mentioning: placeholders and plural/select rules inside XLIFF are fragile. ICU expressions often get split into multiple lines, and sometimes you’ll see unexpected \n characters or odd whitespace. A practical workaround is to keep ICU expressions on a single line in your templates, for example:

<span i18n>{tasksCount, plural, zero {no tasks} one {one task} other {{{tasksCount}} tasks}}</span>

This usually results in cleaner XLF output. If something still looks strange, you can adjust the formatting later inside your TMS.

Finally, even if you’re using AI-powered translations, always review anything that involves ICU. AI normally won’t touch placeholders, but it can still mis-handle plural forms, skip branches in select, or misinterpret gendered wording. A quick manual check saves a lot of trouble.

Workflow configuration

Let's suppose you already have a GitHub repository to host your code (if not, create one now!). Add a new file in your Angular app: .github/workflows/push.yml. Paste the following inside:

name: Push to Lokalise  
on:  
  workflow_dispatch:

jobs:  
  build:  
    runs-on: ubuntu-latest
    steps:
      - name: Checkout Repo
        uses: actions/checkout@v6
        with:
          fetch-depth: 0

      - name: Push files to Lokalise
        uses: lokalise/lokalise-push-action@v4.2.0
        with:
          api_token: ${{ secrets.LOKALISE_API_TOKEN }}
          project_id: 439128966977755bcd8633.61019211
          file_ext: xlf
          translations_path: |
            src/locale
          base_lang: en_US
          name_pattern: messages.en-US.xlf
          additional_params: >
            {
              "convert_placeholders": true,
              "detect_icu_plurals": true
            }

Key points to note

  • api_token — your Lokalise API token generated in the personal profile. Store it in repo secrets (I’ll show where to configure them in a moment). Never hard-code tokens inside workflows! Keep in mind that a token has the same permissions as your user account, so if you don't have access to a project, the token won’t have it either.
  • project_id — your Lokalise project ID. It’s not sensitive information, but you can still place it in secrets if you prefer.
  • file_ext — the extension of your translation files without a leading dot. Since we’re using XLIFF, that would be xlf.
  • translations_path — the folder that contains your translation files, e.g., src/locale. Don’t add leading dots or prefixes like ./ as the action resolves paths relative to the repo root automatically.
  • base_lang — your source language, in our case English (United States). Note that Lokalise uses an underscore in region codes (for example: en_US).
  • name_pattern — provide a proper pattern for the "base" file with translations. The pattern supports more complex expressions, such as en/**/custom_*.json, which would match all nested JSON files under the en folder inside translations_path.
  • Finally, I’m also passing a couple of optional Lokalise API parameters via additional_params. We're converting placeholders into universal format (useful when exporting to different file types later) and enabling ICU plural detection since we’re using plurals in this project.

Repository configuration

We also need to make some changes in your GitHub repo. Proceed to Settings > Actions > General and set Workflow permissions to Read and write permissions. This is required because the push action creates system tags to track its own status. Also, if you're planning to configure the pull action later, tick Allow GitHub Actions to create and approve pull requests.

Next, switch to Secrets and variables > Actions. Here, create a new secret named LOKALISE_API_TOKEN and paste your token as a value. Add other variables as needed.

Running the push action

Awesome, now you can commit and push your changes to GitHub. Then, proceed to the Actions tab and find the "Push" action. Switch to it and click Run workflow.

2026-01-26 17_18_45-Push to Lokalise · Workflow runs · bodrovis_Lokalise-Angular-Demo — LibreWolf.webp

A few notes:

  • You can choose any repo branch to run the workflow for.
  • The workflow might take 1-2 minutes to complete because your configured automation will translate texts into the target languages. You can adjust timeouts easily (if you have long-running workflows) — check the docs to learn more.
  • The push action always uploads translation files for the base language only. This is by design. Later you can reupload, say, Latvian translation manually via Lokalise UI while ticking Detect ICU plurals and Replace modified values. But as long as we've already configured an automation, your Latvian texts will appear on Lokalise anyways.
  • Initially the push action uploads all base language files. On subsequent runs it will detect changes to these files since the last commit and upload only the files that had any modifications. If you prefer to compare changes between action runs (not between last two commits), set the use_tag_tracking parameter to true.

And here's the result on Lokalise:

2026-01-26 17_25_13-Angular i18n _ Lokalise — LibreWolf.webp

The master tag has been assigned automatically to represent the branch that triggered the workflow.

Once again: make sure to check the AI-generated translations, especially the ones that involve ICU expressions!

Using GitHub Actions to pull translations files

Workflow configuration

Okay, now let’s set up a workflow that downloads updated translation files from Lokalise back into the repo. Create a new file in your Angular project: .github/workflows/pull.yml. Paste the following:

name: Pull from Lokalise

on:
  workflow_dispatch:

jobs:
  build:
    runs-on: ubuntu-latest

    steps:
      - name: Checkout Repo
        uses: actions/checkout@v6
        with:
          fetch-depth: 0

      - name: Pull from Lokalise
        uses: lokalise/lokalise-pull-action@v4.5.0
        with:
          api_token: ${{ secrets.LOKALISE_API_TOKEN }}
          project_id: 439128966977755bcd8633.61019211
          translations_path: |
            src/locale
          base_lang: en-US
          file_format: xlf
          flat_naming: true
          additional_params: >
            {
              "indentation": "2sp",
              "export_empty_as": "skip",
              "export_sort": "a_z",
              "plural_format": "icu",
              "placeholder_format": "icu",
              "include_description": true,
              "language_mapping": [
                {"original_language_iso": "en_US", "custom_language_iso": "en-US"}
              ]
            }

Many parameters here mirror those used in the push action, but here are the key new ones:

  • base_lang — set to en-US (with a dash) to match Angular’s locale format. If regional variants annoy you (and the app doesn't rely on them), using just en as your base locale is also fine.
  • flat_naming — enable this because our translation files sit directly inside src/locale. If your repo organizes translations into folders like locale/en/…, then keep this option off.
  • additional_params — several useful adjustments:
    • plural_format / placeholder_format = icu — ensures correct ICU handling.
    • include_description = true — pulls key descriptions when available.
    • language_mapping — demonstrates how to map en_USen-US. This isn’t required for our current setup, because the pull action usually downloads only target languages. But if you ever enable always_pull_base, mapping becomes necessary.
    • You can also fine-tune downloads further. For example, adding filter_langs helps restrict which languages are pulled, making the overall workflow faster and the bundle smaller.

Running the pull action

Commit your workflow, push it to the repo, then go to the Actions tab. Find the “Pull from Lokalise” workflow and trigger it manually. After a short delay, you should see an automatically created pull request containing updated translation files (unchanged files are left untouched).

2026-01-26 19_55_35-Lokalise translations update by github-actions[bot] · Pull Request #3 · bodrovis.webp

That’s it! Review the PR and merge when ready. After pulling the updates locally, your Angular app will use the newly downloaded translations.

At this point you can also add French (or any other language) to your supported locales by adjusting your Angular configuration as described earlier.

Next steps and further reading

In this article, we explored how to implement internationalization in an Angular application using the built-in localization system. We configured locale builds, added translations to templates and components, worked with ICU pluralization and select rules, and created a simple language switcher. We also added routing, verified that localization works across multiple pages, and deployed a fully localized multi-build application to Firebase Hosting.

That's all for today, folks. I hope you found this article useful. Thanks for staying with me, and I'll see you next time!

Easily manage angular i18n translation files

Grab a FREE Lokalise trial and start internationalizing your Angular app

Start now
Developer Guides & Tutorials

Author

1517544791599.jpg

Lead of content, SDK/integrations dev

Ilya is the lead for content, documentation, and onboarding at Lokalise, where he focuses on helping engineering teams build reliable internationalization workflows. With a background at Microsoft and Cisco, he combines practical development experience with a deep understanding of global product delivery, localization systems, and developer education.

He specializes in i18n architectures across modern frameworks — including Vue, Angular, Rails, and custom localization pipelines — and has hands-on experience with Ruby, JavaScript, Python, and Elixir. His work often centers on improving translation workflows, automation, and cross-team collaboration between engineering, product, and localization teams.

Beyond his role at Lokalise, Ilya is an IT educator and author who publishes technical guides, best-practice breakdowns, and hands-on tutorials. He regularly contributes to open-source projects and maintains a long-standing passion for teaching, making complex internationalization topics accessible to developers of all backgrounds.

Outside of work, he keeps learning new technologies, writes educational content, stays active through sports, and plays music. His goal is simple: help developers ship globally-ready software without unnecessary complexity.

vercel

Build a smooth translation pipeline with Lokalise and Vercel

Internationalization can sometimes feel like a massive headache. Juggling multiple JSON files, keeping translations in sync, and redeploying every time you tweak a string… What if you could offload most of that grunt work to a modern toolchain and let your CI/CD do the heavy lifting? In this guide, we’ll wire up a Next.js 15 project hosted on Vercel. It will load translation files on demand f

Updated on August 13, 2025·Ilya Krukowski
Hero GitHub

Hands‑on guide to GitHub Actions for Lokalise translation sync: A deep dive

In this tutorial, we’ll set up GitHub Actions to manage translation files using Lokalise: no manual uploads or downloads, no reinventing a bicycle. Instead of relying on the Lokalise GitHub app, we’ll use open-source GitHub Actions. These let you push and pull translation files directly via the API in an automated way. You’ll learn how to: Push translation files from your repo to LokalisePull translated content back and open pull requests automaticallyWork w

Updated on August 4, 2025·Ilya Krukowski
Lokalise api and webhooks illustration

Building an AI-powered translation flow using Lokalise API and webhooks

Managing translations in a growing product can quickly become repetitive and error-prone, especially when dealing with frequent content updates or multiple languages. Lokalise helps automate this process, and with the right setup you can build a full AI-powered translation pipeline that runs with minimal manual input. In this guide, you’ll learn how to: Upload translation files to Lokalise automaticallyCreate AI-based translation tasksUse webhooks to downloa

Updated on July 22, 2025·Ilya Krukowski

Stop wasting time with manual localization tasks.

Launch global products days from now.

  • Lokalise_Arduino_logo_28732514bb (1).svg
  • mastercard_logo2.svg
  • 1273-Starbucks_logo.svg
  • 1277_Withings_logo_826d84320d (1).svg
  • Revolut_logo2.svg
  • hyuindai_logo2.svg