Skip to main content

Documentation Index

Fetch the complete documentation index at: https://resources.devweekends.com/llms.txt

Use this file to discover all available pages before exploring further.

Angular Internationalization

Internationalization Overview

Estimated Time: 2 hours | Difficulty: Intermediate | Prerequisites: Components, Routing
Internationalization (i18n) is the process of designing your application to support multiple languages and locales. Think of it like building a house with interchangeable wall panels — the structure stays the same, but the surfaces can swap out depending on who is living there. Localization (l10n) is the actual process of creating those panels for each locale. Angular provides built-in i18n tools and also supports third-party libraries like ngx-translate and Transloco. The choice between them is one of the most consequential architectural decisions you will make early in a project, because switching later is extremely painful — every template and every string literal gets touched.
Choose early, or pay later. Retrofitting i18n onto an existing app means touching every template in your codebase. If there is even a 20% chance your app will need multiple languages, set up the i18n infrastructure on day one, even if you only ship one language initially.
┌─────────────────────────────────────────────────────────────────────────┐
│              Angular i18n Approaches                                     │
├─────────────────────────────────────────────────────────────────────────┤
│                                                                          │
│   Built-in i18n (AOT)             Runtime i18n (ngx-translate/Transloco)│
│   ─────────────────────           ──────────────────────────────────────│
│                                                                          │
│   ✓ Best performance              ✓ Switch languages at runtime        │
│   ✓ AOT compilation               ✓ Single bundle for all languages    │
│   ✓ ICU message format            ✓ Lazy load translations             │
│   ✗ Separate build per locale     ✓ Easier to manage                   │
│   ✗ No runtime switching          ✓ API/CMS integration                │
│                                   ✗ Larger bundle size                  │
│                                                                          │
│   Best for: Marketing sites,      Best for: Apps, dashboards,          │
│   SEO-critical pages              admin panels, SPAs                    │
│                                                                          │
└─────────────────────────────────────────────────────────────────────────┘

Built-in Angular i18n

Setup

# Add localize package
ng add @angular/localize

# Generate translation file
ng extract-i18n --output-path src/locale

Marking Text for Translation

<!-- Using i18n attribute -->
<h1 i18n>Welcome to our application</h1>

<!-- With description and meaning -->
<h1 i18n="site header|Main welcome message">Welcome to our application</h1>

<!-- With custom ID for stable translations -->
<h1 i18n="@@welcomeHeader">Welcome to our application</h1>

<!-- Translate attributes -->
<img [src]="logo" i18n-alt alt="Company logo" i18n-title title="Click to go home" />

<!-- Plural expressions -->
<span i18n>{count, plural, 
  =0 {No items}
  =1 {One item}
  other {{{count}} items}
}</span>

<!-- Select expressions -->
<span i18n>{gender, select,
  male {He is online}
  female {She is online}
  other {They are online}
}</span>

<!-- Nested ICU expressions -->
<span i18n>{gender, select,
  male {He has {count, plural, =0 {no messages} =1 {one message} other {{{count}} messages}}}
  female {She has {count, plural, =0 {no messages} =1 {one message} other {{{count}} messages}}}
  other {They have {count, plural, =0 {no messages} =1 {one message} other {{{count}} messages}}}
}</span>

Translation File (messages.xlf)

<?xml version="1.0" encoding="UTF-8" ?>
<xliff version="1.2" xmlns="urn:oasis:names:tc:xliff:document:1.2">
  <file source-language="en" datatype="plaintext" original="ng2.template">
    <body>
      <trans-unit id="welcomeHeader" datatype="html">
        <source>Welcome to our application</source>
        <target>Bienvenido a nuestra aplicación</target>
        <note priority="1" from="description">Main welcome message</note>
        <note priority="1" from="meaning">site header</note>
      </trans-unit>
      
      <trans-unit id="itemCount" datatype="html">
        <source>{VAR_PLURAL, plural, =0 {No items} =1 {One item} other {<x id="INTERPOLATION"/> items}}</source>
        <target>{VAR_PLURAL, plural, =0 {Sin elementos} =1 {Un elemento} other {<x id="INTERPOLATION"/> elementos}}</target>
      </trans-unit>
    </body>
  </file>
</xliff>

Build Configuration

// angular.json
{
  "projects": {
    "my-app": {
      "i18n": {
        "sourceLocale": "en-US",
        "locales": {
          "es": {
            "translation": "src/locale/messages.es.xlf",
            "baseHref": "/es/"
          },
          "fr": {
            "translation": "src/locale/messages.fr.xlf",
            "baseHref": "/fr/"
          },
          "de": "src/locale/messages.de.xlf"
        }
      },
      "architect": {
        "build": {
          "options": {
            "localize": true
          },
          "configurations": {
            "es": {
              "localize": ["es"]
            },
            "fr": {
              "localize": ["fr"]
            }
          }
        },
        "serve": {
          "configurations": {
            "es": {
              "buildTarget": "my-app:build:development,es"
            }
          }
        }
      }
    }
  }
}
# Build all locales
ng build --localize

# Build specific locale
ng build --configuration=es

# Serve specific locale
ng serve --configuration=es

Setup

npm install @jsverse/transloco
ng add @jsverse/transloco

Configuration

// transloco.config.ts
import { TranslocoGlobalConfig } from '@jsverse/transloco-utils';

const config: TranslocoGlobalConfig = {
  rootTranslationsPath: 'src/assets/i18n/',
  langs: ['en', 'es', 'fr', 'de'],
  defaultLang: 'en'
};

export default config;

// app.config.ts
import { provideTransloco, TranslocoModule } from '@jsverse/transloco';
import { TranslocoHttpLoader } from './transloco-loader';

export const appConfig: ApplicationConfig = {
  providers: [
    provideTransloco({
      config: {
        availableLangs: ['en', 'es', 'fr', 'de'],
        defaultLang: 'en',
        fallbackLang: 'en',
        reRenderOnLangChange: true,
        prodMode: !isDevMode(),
        missingHandler: {
          useFallbackTranslation: true,
          logMissingKey: true
        }
      },
      loader: TranslocoHttpLoader
    })
  ]
};

// transloco-loader.ts
@Injectable({ providedIn: 'root' })
export class TranslocoHttpLoader implements TranslocoLoader {
  private http = inject(HttpClient);
  
  getTranslation(lang: string) {
    return this.http.get<Translation>(`/assets/i18n/${lang}.json`);
  }
}

Translation Files

// assets/i18n/en.json
{
  "common": {
    "save": "Save",
    "cancel": "Cancel",
    "delete": "Delete",
    "edit": "Edit",
    "loading": "Loading...",
    "error": "An error occurred"
  },
  "auth": {
    "login": "Log In",
    "logout": "Log Out",
    "register": "Register",
    "email": "Email",
    "password": "Password",
    "forgotPassword": "Forgot Password?",
    "welcomeBack": "Welcome back, {{name}}!"
  },
  "products": {
    "title": "Products",
    "addToCart": "Add to Cart",
    "outOfStock": "Out of Stock",
    "price": "Price: {{price, currency}}",
    "itemCount": {
      "zero": "No items",
      "one": "1 item",
      "other": "{{count}} items"
    }
  },
  "validation": {
    "required": "This field is required",
    "email": "Please enter a valid email",
    "minLength": "Minimum {{min}} characters required",
    "maxLength": "Maximum {{max}} characters allowed"
  }
}

// assets/i18n/es.json
{
  "common": {
    "save": "Guardar",
    "cancel": "Cancelar",
    "delete": "Eliminar",
    "edit": "Editar",
    "loading": "Cargando...",
    "error": "Ocurrió un error"
  },
  "auth": {
    "login": "Iniciar Sesión",
    "logout": "Cerrar Sesión",
    "register": "Registrarse",
    "email": "Correo Electrónico",
    "password": "Contraseña",
    "forgotPassword": "¿Olvidaste tu contraseña?",
    "welcomeBack": "¡Bienvenido de nuevo, {{name}}!"
  },
  "products": {
    "title": "Productos",
    "addToCart": "Añadir al Carrito",
    "outOfStock": "Agotado",
    "price": "Precio: {{price, currency}}",
    "itemCount": {
      "zero": "Sin artículos",
      "one": "1 artículo",
      "other": "{{count}} artículos"
    }
  }
}

Using Transloco in Components

// product-list.component.ts
import { TranslocoModule, TranslocoService } from '@jsverse/transloco';

@Component({
  selector: 'app-product-list',
  standalone: true,
  imports: [TranslocoModule],
  template: `
    <!-- Structural directive approach (recommended) -->
    <ng-container *transloco="let t; read: 'products'">
      <h1>{{ t('title') }}</h1>
      
      @for (product of products(); track product.id) {
        <div class="product-card">
          <h3>{{ product.name }}</h3>
          <p>{{ t('price', { price: product.price }) }}</p>
          
          @if (product.inStock) {
            <button>{{ t('addToCart') }}</button>
          } @else {
            <span class="out-of-stock">{{ t('outOfStock') }}</span>
          }
        </div>
      }
      
      <p>{{ t('itemCount', { count: products().length }) }}</p>
    </ng-container>
    
    <!-- Pipe approach -->
    <button>{{ 'common.save' | transloco }}</button>
    
    <!-- With params -->
    <p>{{ 'auth.welcomeBack' | transloco:{ name: userName() } }}</p>
  `
})
export class ProductListComponent {
  private transloco = inject(TranslocoService);
  
  products = input.required<Product[]>();
  userName = signal('John');
  
  // Programmatic translation
  showSuccessMessage() {
    const message = this.transloco.translate('common.saved');
    // Or with params
    const greeting = this.transloco.translate('auth.welcomeBack', { 
      name: this.userName() 
    });
  }
  
  // Observe translations (reactive)
  message$ = this.transloco.selectTranslate('products.title');
}

Language Switcher

// language-switcher.component.ts
@Component({
  selector: 'app-language-switcher',
  standalone: true,
  imports: [TranslocoModule],
  template: `
    <div class="language-switcher">
      @for (lang of availableLangs; track lang.id) {
        <button
          [class.active]="lang.id === activeLang()"
          (click)="switchLang(lang.id)"
        >
          {{ lang.label }}
        </button>
      }
    </div>
  `
})
export class LanguageSwitcherComponent {
  private transloco = inject(TranslocoService);
  
  availableLangs = [
    { id: 'en', label: 'English' },
    { id: 'es', label: 'Español' },
    { id: 'fr', label: 'Français' },
    { id: 'de', label: 'Deutsch' }
  ];
  
  activeLang = signal(this.transloco.getActiveLang());
  
  constructor() {
    // Subscribe to language changes
    this.transloco.langChanges$.subscribe(lang => {
      this.activeLang.set(lang);
      // Persist preference
      localStorage.setItem('preferredLang', lang);
      // Update document lang attribute
      document.documentElement.lang = lang;
    });
  }
  
  switchLang(lang: string) {
    this.transloco.setActiveLang(lang);
  }
}

Scoped Translations (Lazy Loading)

// features/admin/admin.routes.ts
export const ADMIN_ROUTES: Routes = [
  {
    path: '',
    loadComponent: () => import('./admin.component'),
    providers: [
      provideTranslocoScope({
        scope: 'admin',
        loader: {
          en: () => import('./i18n/en.json'),
          es: () => import('./i18n/es.json')
        }
      })
    ]
  }
];

// features/admin/admin.component.ts
@Component({
  template: `
    <ng-container *transloco="let t; read: 'admin'">
      <h1>{{ t('dashboard.title') }}</h1>
      <p>{{ t('dashboard.welcome') }}</p>
    </ng-container>
  `
})
export class AdminComponent {}

Locale-Aware Formatting

Number, Date, and Currency Formatting

// locale.service.ts
@Injectable({ providedIn: 'root' })
export class LocaleService {
  private transloco = inject(TranslocoService);
  
  private localeMap: Record<string, string> = {
    'en': 'en-US',
    'es': 'es-ES',
    'fr': 'fr-FR',
    'de': 'de-DE'
  };
  
  get currentLocale(): string {
    return this.localeMap[this.transloco.getActiveLang()] ?? 'en-US';
  }
  
  formatNumber(value: number, options?: Intl.NumberFormatOptions): string {
    return new Intl.NumberFormat(this.currentLocale, options).format(value);
  }
  
  formatCurrency(value: number, currency = 'USD'): string {
    return new Intl.NumberFormat(this.currentLocale, {
      style: 'currency',
      currency
    }).format(value);
  }
  
  formatDate(date: Date, options?: Intl.DateTimeFormatOptions): string {
    return new Intl.DateTimeFormat(this.currentLocale, options).format(date);
  }
  
  formatRelativeTime(value: number, unit: Intl.RelativeTimeFormatUnit): string {
    return new Intl.RelativeTimeFormat(this.currentLocale, {
      numeric: 'auto'
    }).format(value, unit);
  }
}

// Custom pipe for locale-aware formatting
@Pipe({
  name: 'localeCurrency',
  standalone: true,
  pure: false
})
export class LocaleCurrencyPipe implements PipeTransform {
  private locale = inject(LocaleService);
  
  transform(value: number, currency = 'USD'): string {
    return this.locale.formatCurrency(value, currency);
  }
}

// Usage
@Component({
  imports: [LocaleCurrencyPipe],
  template: `
    <span>{{ product.price | localeCurrency:'EUR' }}</span>
  `
})
export class PriceComponent {}

RTL (Right-to-Left) Support

// rtl.service.ts
@Injectable({ providedIn: 'root' })
export class RtlService {
  private transloco = inject(TranslocoService);
  
  private rtlLanguages = ['ar', 'he', 'fa', 'ur'];
  
  isRtl = signal(false);
  
  constructor() {
    this.transloco.langChanges$.subscribe(lang => {
      const isRtl = this.rtlLanguages.includes(lang);
      this.isRtl.set(isRtl);
      
      document.documentElement.dir = isRtl ? 'rtl' : 'ltr';
      document.documentElement.lang = lang;
    });
  }
}

// app.component.ts
@Component({
  template: `
    <div [class.rtl]="rtl.isRtl()" [dir]="rtl.isRtl() ? 'rtl' : 'ltr'">
      <router-outlet />
    </div>
  `,
  styles: [`
    :host {
      --spacing-start: 1rem;
      --spacing-end: 0;
    }
    
    :host-context([dir="rtl"]) {
      --spacing-start: 0;
      --spacing-end: 1rem;
    }
    
    .card {
      margin-inline-start: var(--spacing-start);
      margin-inline-end: var(--spacing-end);
    }
  `]
})
export class AppComponent {
  rtl = inject(RtlService);
}

Best Practices

Use Translation Keys

Use meaningful, hierarchical keys like products.details.price

Avoid Concatenation

Never concatenate translated strings - use placeholders instead

Handle Plurals Properly

Use ICU message format for pluralization

Lazy Load Translations

Load feature-specific translations on demand
// ❌ Bad - String concatenation breaks in languages with different word order.
// German says "Willkommen zurück, Max!" but Japanese puts the name first.
const message = translate('hello') + ' ' + userName + '!';

// ✅ Good - Placeholders let each language put the variable where it belongs.
// EN: "Hello, {{name}}!"  JA: "{{name}}さん、こんにちは!"
const message = translate('greeting', { name: userName });

// ❌ Bad - Many languages have more than two plural forms.
// Polish has different forms for 1, 2-4, 5-21, and 22+.
const items = count === 1 ? 'item' : 'items';

// ✅ Good - ICU plural format handles every language's plural rules.
// Translation: "{count, plural, =1 {# item} other {# items}}"
const items = translate('itemCount', { count });
Practical tip: Never embed punctuation (periods, exclamation marks, colons) outside of translation strings. Languages use different punctuation — French puts a space before colons, Spanish uses inverted question marks, and Japanese uses different full stops. Let the translator handle all punctuation inside the translated string.

Next: Accessibility (a11y)

Build accessible Angular applications for all users