LocalePack
ChromeFirefoxEdgeOperaSafariCWS-vermelding
Vue.jsReact
Next.jsi18nextReact Native
Dev-handleidingenSuccesverhalen
Home/Guides/CRXJS i18n
August 8, 2026

Adding i18n to a CRXJS extension

CRXJS is a Vite plugin for building browser extensions, and its published documentation has no i18n or localization page. That does not mean localization is unsupported — it means CRXJS does not wrap it, so you use the browser API directly and rely on Vite’s static-asset rules to get _locales into the build. This guide is that pattern, written down.

What CRXJS does and does not give you

Being precise here saves an afternoon of searching. CRXJS generates your manifest.json from a typed config file, wires up HMR for content scripts and popups, and manages web-accessible resources. It does not ship a translation helper, a message catalogue loader, or a typed t() function.

CRXJS handles

  • ✓Building manifest.json from manifest.config.ts
  • ✓Bundling popup, options, content scripts and the service worker
  • ✓HMR during development for source files in the module graph

You handle

  • →Creating the _locales folder and messages.json catalogues
  • →Declaring default_locale in manifest.config.ts
  • →Calling chrome.i18n.getMessage() at runtime
Contrast this with WXT, which ships an official i18n module, or Plasmo, which defines its own accepted locale directories. CRXJS deliberately stays close to plain Vite — which is good news, because Vite’s static-asset behaviour is well specified and gives you a layout that works without any plugin at all.

The layout: put _locales under public/

Vite copies the contents of publicDir to the output root verbatim — no hashing, no renaming, no transformation. That is exactly the guarantee a _locales folder needs, because Chrome looks for catalogues at a fixed path relative to the extension root.

Source tree:

my-extension/
├── manifest.config.ts
├── vite.config.ts
├── package.json
├── public/
│   └── _locales/              ← copied verbatim to the output root
│       ├── en/
│       │   └── messages.json
│       ├── de/
│       │   └── messages.json
│       └── ja/
│           └── messages.json
└── src/
    ├── popup/
    │   ├── index.html
    │   └── main.tsx
    └── content/
        └── main.tsx

Built tree after vite build:

dist/
├── manifest.json              ← generated by CRXJS from manifest.config.ts
├── _locales/                  ← the public/ subtree, unchanged
│   ├── en/
│   │   └── messages.json
│   ├── de/
│   │   └── messages.json
│   └── ja/
│       └── messages.json
├── assets/
│   ├── main-a1b2c3d4.js       ← hashed, because it came from the module graph
│   └── popup-e5f6g7h8.css
└── src/
    └── popup/
        └── index.html

Note the difference in the two output directories. Files in assets/ have content hashes appended because Vite processed them. The _locales tree does not, because Vite copied it. Chrome requires the second behaviour: it will open _locales/de/messages.json by that exact name and nothing else.

Why src/_locales/ does not work

This is the mistake that costs the most time, because it fails silently: the build succeeds, the extension loads, and every string is empty.

Anything under src/ is treated as a potential input to the module graph, not as a static asset. Two consequences follow:

  • •Unimported files are not emitted. A messages.json that no module imports is dead weight to the bundler. Nothing references it, so nothing copies it, and it never appears in dist/.
  • •Imported files get renamed. If you do import it to force emission, it comes out as a hashed asset such as assets/messages-9f8e7d6c.json. Chrome does not look there and has no way to be told to.

What the failure looks like at runtime:

> chrome.i18n.getMessage("appName")
< ""

An empty string, not an exception. Per the Chrome documentation, “If the message is missing, this method returns an empty string (‘’).” Your popup renders with blank labels and no console error to explain it. If you hit this, our guide on getMessage() returning an empty string walks the full diagnostic tree.

A quick way to confirm the diagnosis without reading any code: open dist/ after a build and check whether a directory literally named _locales sits next to manifest.json. If it does not, no amount of runtime code will fix it.

default_locale in manifest.config.ts

CRXJS defines the manifest in manifest.config.ts using defineManifest, and that object is passed to the plugin in vite.config.ts. There is no separate manifest.json to edit — default_locale goes in the config, and CRXJS writes it into the generated manifest.

// manifest.config.ts
import { defineManifest } from '@crxjs/vite-plugin'
import pkg from './package.json'

export default defineManifest({
  manifest_version: 3,
  name: '__MSG_appName__',
  description: '__MSG_appDescription__',
  version: pkg.version,
  default_locale: 'en',          // required once _locales/ exists
  action: {
    default_popup: 'src/popup/index.html',
    default_title: '__MSG_actionTitle__',
  },
})

And the Vite config that consumes it:

// vite.config.ts
import { crx } from '@crxjs/vite-plugin'
import { defineConfig } from 'vite'
import manifest from './manifest.config.js'

export default defineConfig({
  plugins: [
    crx({ manifest }),
  ],
})
default_locale and _locales/ are a matched pair. Add the folder without the field and Chrome refuses to load the extension with default_locale is required if _locales/ is present. The full list of failure modes is in default_locale: rules and common errors.

__MSG_ substitution in the generated manifest

Chrome substitutes __MSG_key__ tokens in a handful of manifest fields — most usefully name and description, which is what users see in the extensions page and the Web Store listing. Because CRXJS generates the manifest from your TypeScript config, you write the tokens as plain strings in manifest.config.ts and they pass through unchanged.

Every referenced key must exist in the default locale catalogue:

// public/_locales/en/messages.json
{
  "appName": {
    "message": "Tab Sorter",
    "description": "Extension name shown in the Chrome Web Store"
  },
  "appDescription": {
    "message": "Sort and group your open tabs in one click.",
    "description": "Store listing description, 132 characters max"
  },
  "actionTitle": {
    "message": "Sort tabs",
    "description": "Tooltip on the toolbar icon"
  }
}

The token in the manifest is __MSG_appName__ and the key in the catalogue is appName — the underscores are the delimiter, not part of the key. Key lookup is case-insensitive, but keeping the casing identical avoids a whole class of confusing bugs. See using __MSG_key__ in manifest.json for which fields accept substitution.

Runtime: call chrome.i18n directly

There is no CRXJS import for this. You call the browser API from your popup, options page, content script or service worker exactly as you would in a plain, bundler-free extension.

// src/popup/main.tsx
const t = (key: string, subs?: string[]) => chrome.i18n.getMessage(key, subs)

export function Popup() {
  return (
    <div>
      <h1>{t('appName')}</h1>
      <p>{t('greeting', ['Ada'])}</p>
      <button>{t('actionTitle')}</button>
    </div>
  )
}

Two properties of getMessage() shape how you use it:

  • •It is synchronous. It returns a string, not a promise. No loading state, no suspense boundary, no await — it works inline in a render function, which is why the one-line t() helper above is enough for most extensions.
  • •It never throws for a missing key. Per the Chrome documentation, “If the message is missing, this method returns an empty string (‘’).” A typo in a key produces a blank label, not an error. Budget for a validation step in CI rather than relying on runtime failures to catch drift.
Because the failure mode is silence, a t() wrapper that warns when the result is empty pays for itself:
const t = (key: string, subs?: string[]) => {
  const value = chrome.i18n.getMessage(key, subs)
  if (import.meta.env.DEV && !value) console.warn('missing i18n key:', key)
  return value
}

The dev-server caveat: catalogues do not hot-reload

CRXJS advertises true HMR, including for content scripts, and it delivers — for files in the module graph. Your messages.json files are not in the module graph. They are static assets under public/, copied rather than compiled, so the mechanism that pushes source edits into a running extension does not apply to them.

You editWhat you must do
src/popup/main.tsxNothing — HMR pushes the change automatically
src/content/main.tsxNothing — CRXJS handles content-script HMR
public/_locales/en/messages.jsonReload the extension, then reopen the popup
manifest.config.tsRestart the dev server — the manifest is generated at startup

In practice the workflow is: change the string, then reload the extension from chrome://extensions and reopen the surface you are testing. It is worth knowing this up front, because the natural assumption after seeing content-script HMR work is that a stale string means your code is wrong — when it only means the asset was not re-served.

Packaging: vite-plugin-zip-pack

CRXJS projects package for the Web Store with vite-plugin-zip-pack, which zips the build output after vite build finishes. Because it archives whatever is in the input directory, and _locales is already there by then, no extra configuration is needed for localization.

// vite.config.ts
import { crx } from '@crxjs/vite-plugin'
import { defineConfig } from 'vite'
import zip from 'vite-plugin-zip-pack'
import manifest from './manifest.config.js'
import { name, version } from './package.json'

export default defineConfig({
  plugins: [
    crx({ manifest }),
    zip({ outDir: 'release', outFileName: `crx-${name}-${version}.zip` }),
  ],
})

The plugin defaults to reading from dist and writing to dist-zip; the example above overrides the output directory and file name only. If your build writes elsewhere, set inDir to match.

Verify the catalogues actually shipped:

$ unzip -l release/crx-tab-sorter-1.0.0.zip | grep _locales

      412  08-08-2026 10:22   _locales/en/messages.json
      438  08-08-2026 10:22   _locales/de/messages.json
      451  08-08-2026 10:22   _locales/ja/messages.json

No output from that command means no catalogues in the archive. Uploading it would give you an extension that shows blank strings for every user, including the ones who would have matched your default locale. Make this grep a step in your release script — it costs nothing and catches the one mistake that is invisible until users report it.

Verification checklist

Run this once after wiring i18n up. Every step is observable — none of it depends on reading code.

1

Build and inspect the output root

Run vite build and confirm dist/_locales/ sits next to dist/manifest.json. If it is missing, your catalogues are under src/ rather than public/.

2

Confirm default_locale reached the generated manifest

Open dist/manifest.json and check that default_locale is present and matches a folder name inside dist/_locales/ exactly, including case.

3

Load unpacked

Go to chrome://extensions, enable Developer mode, and load the dist/ folder. A missing or mismatched default_locale fails here, loudly, before anything else runs.

4

Check the name and description

The extension card should show your translated name, not the literal string __MSG_appName__. A literal token means the key is absent from the default locale catalogue.

5

Call getMessage() from the popup console

Right-click the popup, choose Inspect, and run chrome.i18n.getMessage('appName'). An empty string means the key is missing; a correct string means the catalogue loaded.

6

Switch Chrome's UI language and reload

In Chrome settings, change the display language to one you translated, restart the browser, and reopen the popup. The strings should change. This is the only step that proves the non-default locales work.

Switching Chrome’s UI language is the only honest end-to-end test. Everything short of it — inspecting dist/, calling getMessage() in the console — confirms that the default locale works, which is also what a completely broken fallback chain looks like from the outside.

Where to go once the pattern works

The CRXJS-specific part ends here. Everything past this point is ordinary Chrome extension localization, and it is the same regardless of which bundler produced your dist/:

  • •chrome.i18n: the complete API reference — every method, its return type, and which ones are asynchronous.
  • •The messages.json format explained — every field, plus how placeholders survive translation.
  • •Chrome extension locale codes — the exact folder names Chrome accepts, and what happens to the ones it does not.
  • •Validating messages.json — catching key drift in CI instead of in a user report.

Frequently asked questions

Does CRXJS support i18n?

Yes, but it does not wrap it. CRXJS is a Vite plugin for building extensions; its published documentation has no i18n or localization page, so there is no CRXJS-specific helper or import. You use the browser's chrome.i18n API directly and let Vite's publicDir behaviour place the _locales folder in the build output.

Where do I put _locales in a CRXJS project?

Put it at public/_locales/{lang}/messages.json. Vite copies everything under publicDir to the output root verbatim, so those files land at _locales/{lang}/messages.json inside dist/, which is exactly where Chrome expects them.

Why does src/_locales/ not work in CRXJS?

Anything under src/ is treated as an input to the module graph rather than as a static asset. Vite only emits files from src/ when something imports them, and emitted assets are renamed and hashed. Nothing produces a literal _locales/ directory at the root of dist/, so Chrome never finds the catalogues.

Where does default_locale go in a CRXJS project?

In manifest.config.ts, inside the object you pass to defineManifest(). CRXJS builds the real manifest.json from that config, so default_locale is written into dist/manifest.json for you. If _locales/ exists and default_locale is missing, Chrome refuses to load the extension.

Why do my messages.json edits not hot-reload in CRXJS dev mode?

Files in public/ are static assets, not part of Vite's module graph, so editing them does not trigger the HMR path that source edits do. After changing a messages.json file, reload the extension from chrome://extensions and reopen the popup.

How do I confirm _locales made it into the packaged ZIP?

Run unzip -l on the archive produced by vite-plugin-zip-pack and grep for _locales. Every locale you shipped should appear as _locales/{lang}/messages.json. If the ZIP is empty of _locales, your files were under src/ instead of public/.

Fill public/_locales without a TMS

Once the CRXJS side works, you still need catalogues in every language you ship. LocalePack takes your source messages.json, translates it into 52 locales, and returns a _locales ZIP you unzip straight into public/. Every folder name is a valid Chrome locale code, and placeholders like $PLACEHOLDER$ and $1 are preserved. Pay once — no account, no subscription.

Translate your messages.json into 52 locales →
← Back to Guides
LocalePack
HandleidingenPrivacyVoorwaardenOndersteuning

© 2025 LocalePack. Alle rechten voorbehouden.

Dit project is vertaald met LocalePack logoLocalePack