---
title: Internationalization
description: Ship an Angular app in several languages with vite-plugin-ngp, using Angular's own i18n, message extraction and per-locale builds.
---

# Internationalization

vite-plugin-ngp supports Angular's built-in i18n: `i18n` attributes in templates, `$localize` in TypeScript, translation files in the formats `ng extract-i18n` writes, and one build output per locale. It works the way the Angular CLI's does: `vite build` builds your app once and then writes a translated copy for each locale, so no translation code ships to the browser. The dev server serves one locale at a time, translated in the browser.

This page is for someone whose app already builds with the plugin and now needs a second language. It walks through marking text, extracting it, translating it, and serving the result, then says where the plugin differs from the CLI.

## Setup

Install `@angular/localize` at the same version as the rest of Angular:

```bash
pnpm add @angular/localize@22.3.0-next.2
```

Add its types, so TypeScript knows `$localize`, in `tsconfig.json`:

```json
"types": ["vite/client", "@angular/localize"]
```

Then tell the plugin which locales you have:

```ts
import {defineConfig} from 'vite';
import {ngp} from 'vite-plugin-ngp';

export default defineConfig({
  plugins: [
    ngp({
      i18n: {
        sourceLocale: 'en-US',
        locales: {de: 'src/locale/messages.de.xlf'},
        devLocale: process.env.NGP_LOCALE,
      },
    }),
  ],
});
```

You don't import `@angular/localize/init` anywhere. In the dev server the plugin loads it for you. In builds nothing needs it, because every message is already translated. If your `polyfills` option lists it, carried over from `angular.json`, the plugin drops it, as `ng build` does when it inlines translations.

If you render on the server, add `withI18nSupport()` to `provideClientHydration`. Without it, Angular skips hydrating every component that contains an `i18n` block, and in the repo's playground that was all of them.

## Mark text for translation

Nothing in this step is specific to the plugin. Mark template text with `i18n`, attributes with `i18n-<name>`, and strings in TypeScript with `$localize`:

```html
<h1 i18n="Overview page heading@@overviewHeading">Angular on Vite, <span class="gradient">one module at a time</span>.</h1>
<nav aria-label="Demos" i18n-aria-label>…</nav>
<p i18n>{count, plural, =1 {One page} other {{{ count }} pages}}</p>
```

```ts
label = $localize`:Color theme:Dark`;
```

ngp compiles these exactly as Angular's own compiler does: each message becomes a `$localize` call with the same message id. The ids depend on two compiler options, `enableI18nLegacyMessageIdFormat` and `i18nNormalizeLineEndingsInICUs`. The plugin reads both from `angularCompilerOptions` in your tsconfig, as `ng build` does, so translation files made by the CLI keep working.

## Extract the messages

The plugin's package has a command for what `ng extract-i18n` does:

```bash
ngp-extract-i18n --out-file src/locale/messages.xlf
```

It builds your app in memory without minifying, finds every `$localize` call in the output with `@angular/localize`'s extractor, and writes them as XLIFF 1.2. `--format` also takes `xlf2`, `xmb`, `json` and `arb`. Run it from the project root, or pass `--root` and `--config`. In the playground, a `package.json` script runs it, and it writes 34 messages in under 2 seconds.

Copy the file to `messages.de.xlf`, add `target-language="de"`, and give each `<trans-unit>` a `<target>`. Any tool that edits Angular's XLIFF files works, because the file is the same one the CLI writes.

## The dev server

`vite` serves one locale: `devLocale`, or the source locale if you don't set one. In the config above, `NGP_LOCALE=de pnpm dev` serves German. The plugin reads that locale's translation files, and each entry module first imports a small module that loads `@angular/localize/init`, Angular's locale data for the locale, and the translations. It also sets `<html lang>`. Editing a translation file reloads the page.

Template HMR keeps working: an updated template's messages are translated in the browser like the rest.

## The build

`vite build` writes one folder per locale, named after the locale code:

```
dist/
  en-US/
    index.html
    assets/
  de/
    index.html
    assets/
```

With `ssr`, the same happens inside `dist/client` and `dist/server`, so each locale has its own `main.server.js`.

Vite bundles and minifies your app once. Then the plugin writes every file once per locale. In each JavaScript file it replaces every `$localize` call with the translated string, replaces Angular's reads of `$localize.locale` with the locale code, and adds Angular's locale data for that locale, so `DatePipe` and plural rules work without `registerLocaleData`. Each `index.html` gets `<html lang>` and a `<base href>` of `/<locale>/`. Source maps are updated to match. A chunk has the same file name in every locale, and its hash covers your translation files, so editing a translation gives every chunk a new name and long-lived caches stay correct. A message with no translation keeps the source text and logs a warning. Set `missingTranslation: 'error'` to fail the build instead. A translation unit the parser can't read in full, such as a `{` in the text that isn't an ICU expression, is reported as an error in the log and the build goes on with what the parser read of it, which is what `ng build` does with the same file.

Because every locale sits in its own folder, the plugin builds with relative asset URLs (Vite's `base: './'`), and each page's `<base href>` decides which folder they load from. If you set `base`, for example to `/app/`, the locales are served from `/app/en-US/` and `/app/de/`, and `vite preview` serves them there too. Vite's `build.manifest`, if you turn it on, is written into each locale's folder, with paths relative to that folder.

The translation step is cheap next to the build itself. On the playground, `vite build` with two locales took 2.1 seconds, against 1.8 seconds without i18n, as the average of three runs.

## Serving the locales

**`vite preview`** knows the layout. A request for `/de/...` gets `de/index.html` unless it names a file, and `/` redirects to the first locale in the browser's `Accept-Language` that you built, or to the source locale.

**Your own server** needs the same two rules: serve `<locale>/...` from that locale's folder, and send every other page request to that locale's `index.html`, or to its `render` with SSR. The repo's playground server does this in about 80 lines of `node:http`. It loads each locale's `main.server.js` into one process, which is safe: each bundle has its locale code written in where Angular would read `$localize.locale`, so no bundle depends on what another set globally.

**Static hosts** need a rewrite per locale from `/<locale>/*` to `/<locale>/index.html`, and a redirect from `/`.

## Options

| Option | Default | What it does |
|---|---|---|
| `sourceLocale` | `'en-US'` | The language your templates are written in. A string, or `{code, subPath}` |
| `locales` | none | Locale code to translation file, list of files, or `{translation, subPath}`. Paths are relative to Vite's root |
| `devLocale` | the source locale | The locale `vite` serves |
| `missingTranslation` | `'warning'` | `'error'`, `'warning'` or `'ignore'`, for messages a translation file lacks |

`subPath` is both the output folder and the URL segment, and defaults to the locale code.

## How it differs from the Angular CLI

- **Same output layout, same message ids, same file formats.** A project can move between `ng build --localize` and `vite build` without new translation files.
- **Budgets are checked once, before translation.** The CLI checks each locale's output. Translated text can make a locale's files a few bytes larger or smaller than the checked size.
- **No `localize` option to build some locales only.** Every build writes every locale. With many locales, that's where build time goes.
- **No per-locale `baseHref` option.** Each locale is served from `<base><subPath>/`.
- **Extraction points at the component, not the template.** In the XLIFF file, a message from an external template lists the component's `.ts` file and the decorator's line, not the `.html` line. ngp's source maps don't map generated template code back to the template. Translators lose the exact line; the message and id are the same.

## Limits

- **ICU messages inside `@if` inside `@for` fail hydration.** In Angular 22.3.0-next.2, the browser throws an assertion while hydrating a plural message nested in both blocks, in the source locale too. ngp emits the same instructions as Angular's own compiler there, so it's Angular's runtime, not the plugin. An ICU outside control flow hydrates fine; the playground keeps its plural there.
- **Every locale is a full copy.** Assets and the `public` directory are copied into each locale's folder, as the CLI does. A large `public` directory multiplies with the number of locales.
- **Specs need the `$localize` runtime themselves.** The `i18n` option sets `$localize` up through your app's entry modules, which specs don't import, so a spec that renders a component with `i18n` text fails with "`$localize` is not defined". Add `import '@angular/localize/init';` at the top of your [test setup file](testing), as the playground does.
