On this page
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:
pnpm add @angular/localize@22.3.0-next.2Add its types, so TypeScript knows $localize, in tsconfig.json:
"types": ["vite/client", "@angular/localize"]Then tell the plugin which locales you have:
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:
<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>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:
ngp-extract-i18n --out-file src/locale/messages.xlfIt 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 --localizeandvite buildwithout 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
localizeoption to build some locales only. Every build writes every locale. With many locales, that's where build time goes. - No per-locale
baseHrefoption. 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
.tsfile and the decorator's line, not the.htmlline. 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
@ifinside@forfail 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
publicdirectory are copied into each locale's folder, as the CLI does. A largepublicdirectory multiplies with the number of locales. - Specs need the
$localizeruntime themselves. Thei18noption sets$localizeup through your app's entry modules, which specs don't import, so a spec that renders a component withi18ntext fails with "$localizeis not defined". Addimport '@angular/localize/init';at the top of your test setup file, as the playground does.