LocalePack
ChromeFirefoxEdgeOperaSafariОбява в CWS
Vue.jsReact
Next.jsi18nextReact Native
Ръководства за разработчициИстории на успеха
Home/Guides/WXT i18n
August 8, 2026

WXT i18n: @wxt-dev/i18n and messages.json

@wxt-dev/i18n gives you nicer authoring formats, nested keys, plurals and TypeScript types — but the thing it ships to the browser is still an ordinary _locales/{lang}/messages.json tree. That single fact decides how you translate a WXT extension.

What @wxt-dev/i18n actually is

It is a build-time layer, not a runtime i18n engine. You author messages in a format that is pleasant to edit; the module compiles them into the flat Chrome catalogue format at build time. Nothing about the extension’s loading behaviour changes — the browser reads the same messages.json files it has read since Manifest V2.

Authoring                 Build step                 Shipped output
─────────────────────     ──────────────────────     ──────────────────────────
locales/en.yml        →   @wxt-dev/i18n module   →   _locales/en/messages.json
locales/de.yml        →   (WXT build)            →   _locales/de/messages.json
locales/fr.yml        →                          →   _locales/fr/messages.json

The right-hand column is the plain Chrome format.
This is the practical takeaway for anyone choosing a translation workflow: because the output is the standard Chrome format, anything that translates messages.json works with WXT. You are not locked into a WXT-specific pipeline, and you do not need a tool that understands YAML to localize a WXT extension.

If you have not met the target format yet, read the messages.json format explained first — everything WXT emits obeys those rules.

Setup

Two things are needed: register the module in wxt.config.ts and declare default_locale in the manifest block.

// wxt.config.ts
export default defineConfig({
  modules: ['@wxt-dev/i18n/module'],
  manifest: {
    default_locale: 'en',
  },
});

default_locale is not a WXT invention — it is the standard manifest field, and the standard rules apply to it. WXT writes it straight into the generated manifest. If you get it wrong, you get the ordinary Chrome load errors, which are catalogued in default_locale: rules and common errors.

Authoring formats: YAML, JSON, JSONC, JSON5, TOML

Message files can be written in any of five formats. All five compile to the same output, so the choice is purely about authoring ergonomics.

YAML

The common default. Supports comments, needs no quoting or braces, and nests cleanly — which matters because nested keys are a headline feature.

JSON

Zero new tooling, but no comments and heavy punctuation for deeply nested catalogues.

JSONC

JSON with comments. A reasonable middle ground if you want translator notes next to keys but do not want YAML.

JSON5

Unquoted keys, trailing commas, comments. Less common in extension projects but supported.

TOML

Supported. Rarely chosen for message catalogues because deep nesting is verbose in TOML table syntax.

Most teams land on YAML for the same two reasons they land on it for Vue I18n locale files: comments survive code review, and indentation reads better than nested braces when a catalogue grows past a hundred keys.

i18n.t() vs browser.i18n.getMessage()

Both work, in the same project, at the same time. The module’s i18n.t() reads from the very same compiled catalogue that browser.i18n.getMessage() reads.

// The WXT helper — imported from the #i18n virtual module
import { i18n } from '#i18n';

i18n.t('helloWorld');

// The plain WebExtension API — works too
browser.i18n.getMessage('helloWorld');

Prefer i18n.t() in new code — it is what gives you typed keys and the plural API below. Keep browser.i18n.getMessage() in mind when you are porting an existing extension to WXT: your old call sites do not have to be rewritten in one pass.

Because the runtime is the standard i18n API, all the standard behaviour applies — including the lookup fallback chain and the fact that a missing message resolves to an empty string rather than throwing. See the _locales structure guide for the details.

Nested keys and how they flatten

The Chrome format has one flat namespace: every key in messages.json is a top-level property. WXT lets you nest at authoring time and reach the values with dot notation.

# locales/en.yml
welcome:
  title: Welcome to the extension
  subtitle: Let us get you set up

dialogs:
  confirmation:
    title: Are you sure?

Call sites:

i18n.t('welcome.title');
i18n.t('dialogs.confirmation.title');

The nesting is an authoring convenience only. After the build step the catalogue is flat, exactly as the format requires — the dotted path becomes the key. This is worth internalising before you hand files to a translation tool: the tool sees flat keys, not your YAML tree.

Plurals

Plurals are addressed by passing the count as the second argument to i18n.t().

i18n.t('items', 0);   // zero form
i18n.t('items', 1);   // singular form
i18n.t('items', 2);   // plural form

Custom substitutions are supplied as an array in the third argument, so count selection and substitution are independent:

i18n.t('items', 2, ['Multiple']);
This plural selection happens on the WXT side of the boundary. The compiled Chrome catalogue has no plural concept of its own — it stores strings and $1-style placeholders. If you translate the compiled output rather than the source, treat each plural form as its own string and keep them all populated. Compare with how i18next handles plurals, where the suffix convention is part of the wire format itself.

Substitutions and the $$ escape

Substitutions use the standard positional tokens $1 through $9 — the same tokens the Chrome format uses, because that is what the compiled file contains.

To print a literal dollar sign, double it:

✗Total: $5
✓Total: $$5

A single $ followed by content is parsed as a substitution token. Double it to emit a literal $.

This is the number-one silent corruption in machine-translated extension catalogues: a translator that does not know the format rewrites $1 as $ 1, or collapses a $$ escape back to a single $. Both changes compile without error and only surface as broken strings at runtime. Run a validation pass over translated output before you ship it.

Type safety

WXT generates a wxt-i18n-structure.d.ts describing your catalogue, and you instantiate the helper against it:

createI18n<WxtI18nStructure>();

With that in place, a typo in a key is a compile error rather than an empty string in the UI — which is the more useful half of what the module buys you, given that the runtime API returns "" for a missing message and never throws.

Using the compiler outside WXT

The build API is published separately at @wxt-dev/i18n/build, so you can get the YAML-to-messages.json compilation in a project that does not use WXT at all. It exports parseMessagesFile, generateChromeMessagesFile and generateTypeFile.

import {
  parseMessagesFile,
  generateChromeMessagesFile,
  generateTypeFile,
} from '@wxt-dev/i18n/build';

const messages = await parseMessagesFile('path/locales/en.yml');

await generateChromeMessagesFile('dist/_locales/en/messages.json', messages);
await generateTypeFile('path/i18n-structure.d.ts', messages);

Note the output path: dist/_locales/en/messages.json. That is the real deliverable — a normal Chrome catalogue at a normal Chrome path. If you are on a different framework, this is the escape hatch that lets you keep YAML authoring without adopting WXT wholesale.

Translating a WXT project

There are exactly two places you can hand off for translation, and the right choice depends on whether you value structure or simplicity.

Option A — translate the compiled _locales output

  • ✓It is the plain Chrome format, so every messages.json tool reads it directly with no adapter.
  • ✓Keys are already flat, which is what translation tooling expects.
  • ✓The translated files can be dropped straight into a build output or committed as pre-built locales.
  • !You lose the nested structure and any comments that lived in the YAML source.
  • !Re-running the build regenerates the default locale, so you need a convention for where translated locales live.

Option B — round-trip the source message files

  • ✓Nested structure and comments survive, so the source of truth stays readable.
  • ✓Translations sit next to the code in the same format the team already edits.
  • !You must flatten to dotted keys before sending to a messages.json tool and unflatten on the way back.
  • !Fewer tools accept YAML message catalogues directly, so you are more likely to write glue code.

A pragmatic middle path most teams end up with: keep locales/en.yml as the hand-edited source of truth, build once to produce _locales/en/messages.json, translate that file, and commit the resulting non-English catalogues as build inputs. English stays pleasant to edit; the translated locales stay in the format every tool already understands.

Whichever option you pick, the invariant is the same: the browser only ever sees the flat Chrome catalogue. If the shipped _locales/de/messages.json is correct, it does not matter which side of the build step the translation happened on.

Quick reference

ConcernIn WXT
Module registrationmodules: ['@wxt-dev/i18n/module']
Default localemanifest: { default_locale: 'en' }
Helper importimport { i18n } from '#i18n'
Simple lookupi18n.t('helloWorld')
Nested lookupi18n.t('dialogs.confirmation.title')
Plural lookupi18n.t('items', 2)
Plural + substitutioni18n.t('items', 2, ['Multiple'])
Plain WebExtension APIbrowser.i18n.getMessage('helloWorld')
Substitution tokens$1 … $9 (literal $ is written $$)
Authoring formatsYAML, JSON, JSONC, JSON5, TOML
Typed keyscreateI18n<WxtI18nStructure>()
Standalone compiler@wxt-dev/i18n/build
Compiled output_locales/{lang}/messages.json

Frequently asked questions

Does @wxt-dev/i18n replace the Chrome messages.json format?

No. @wxt-dev/i18n is a build-time layer. You author messages in YAML, JSON, JSONC, JSON5 or TOML, and the module compiles them down to standard _locales/{lang}/messages.json files. The browser still loads the plain Chrome format, so any tool that reads or writes messages.json works with a WXT extension.

Can I still use browser.i18n.getMessage() in a WXT project?

Yes. Plain browser.i18n.getMessage('helloWorld') works, because the compiled output is a standard _locales catalogue. i18n.t() from the #i18n module is a typed convenience wrapper on top of the same messages.

How do nested keys work in @wxt-dev/i18n?

You can nest keys in your message file and reference them with dot notation, such as i18n.t('welcome.title') or i18n.t('dialogs.confirmation.title'). The build step flattens the nesting into the flat key namespace that the Chrome messages.json format requires.

How do plurals work in @wxt-dev/i18n?

Pass the count as the second argument: i18n.t('items', 0), i18n.t('items', 1), i18n.t('items', 2). Custom substitutions go in an array as the third argument, for example i18n.t('items', 2, ['Multiple']).

Can I use @wxt-dev/i18n outside of a WXT project?

Yes. The @wxt-dev/i18n/build entrypoint exports parseMessagesFile, generateChromeMessagesFile and generateTypeFile, so you can run the compiler as a standalone script in any extension project and emit dist/_locales/en/messages.json yourself.

Should I translate my YAML source files or the compiled _locales output?

Both work. Translating the compiled _locales/{lang}/messages.json is simpler and is what a messages.json translator consumes directly. Round-tripping the source YAML keeps the nested structure and comments intact but requires you to flatten and unflatten the keys yourself.

Related guides

  • •The messages.json format explained — the format WXT compiles down to.
  • •Chrome extension _locales structure — folder names, lookup order and fallbacks.
  • •default_locale: rules and common errors — the manifest field WXT writes for you.
  • •Plasmo i18n: the locales/ folder gotcha — the same problem in the other popular extension framework.

Translate the file WXT already produces

Because WXT compiles to standard messages.json, LocalePack works with a WXT project out of the box: upload the compiled catalogue, pay once, download a _locales ZIP. Placeholders such as $PLACEHOLDER$ and $1 are preserved, and all 52 locales are valid Chrome _locales folder names. No account, no subscription.

Translate your messages.json into 52 locales →
← Back to Guides
LocalePack
РъководстваПоверителностУсловияПоддръжка

© 2025 LocalePack. Всички права запазени.

Този проект е преведен с LocalePack logoLocalePack