How to localize a Chrome extension (complete guide)
Chrome’s built-in i18n system is surprisingly capable — but easy to get wrong the first time. This guide covers everything: manifest setup, _locales folder structure, messages.json format, the chrome.i18n API, localizing your manifest strings, testing, and translating to every language the Chrome Web Store supports.
How Chrome extension i18n works
Chrome provides a first-party i18n API through the chrome.i18n namespace. When you enable it, Chrome automatically selects the right locale for each user based on their browser language, with a fallback to your default_locale if their language is not available.
The system has three moving parts:
- A
default_localefield inmanifest.jsonthat tells Chrome which language your source strings are written in. - A
_localesfolder containing one subfolder per language, each with amessages.jsonfile. - The
chrome.i18n.getMessage()API call (in JS) and__MSG_key__substitution tokens (in manifests and CSS) for reading strings at runtime.
All three must be in place before any strings will load. Let’s set them up one at a time.
Step 1: add default_locale to manifest.json
Open your manifest.json and add the default_locale field. Its value is the locale code of the language your source strings are written in — almost always "en":
{
"manifest_version": 3,
"name": "__MSG_appName__",
"description": "__MSG_appDescription__",
"version": "1.0.0",
"default_locale": "en",
"action": {
"default_title": "__MSG_actionTitle__",
"default_popup": "popup.html"
}
}name, description, and default_title all use the __MSG_key__ pattern. This tells Chrome to substitute the localized string from messages.json at load time. The Chrome Web Store also reads these to display your extension name and description in the user’s language.default_locale and the _locales folder must both be present or both absent. Chrome rejects the extension at load time if only one exists.Step 2: create the _locales folder structure
At the root of your extension (next to manifest.json), create a _locales directory. Inside it, create one subfolder per language, each containing a single messages.json file:
my-extension/
├── manifest.json
├── background.js
├── popup.html
├── popup.js
└── _locales/
├── en/
│ └── messages.json ← default_locale (your source strings)
├── de/
│ └── messages.json
├── fr/
│ └── messages.json
├── es/
│ └── messages.json
└── pt_BR/
└── messages.jsonThe folder names must match Chrome’s supported locale codes exactly — including underscore casing for regional variants (e.g. pt_BR not pt-BR). Chrome silently ignores folders with unrecognised names, so a typo means the locale never loads.
Commonly used locale codes: en, de, fr, es, it, pt_BR, pt_PT, ja, ko, zh_CN, zh_TW, ru, ar, hi. See the complete locale code list.
Step 3: write your source messages.json
Every string your extension displays lives in _locales/en/messages.json (your default locale). Each entry is a key with at minimum a message field. An optional description provides context for translators:
{
"appName": {
"message": "Tab Manager Pro",
"description": "The extension's name as shown in the Chrome Web Store."
},
"appDescription": {
"message": "Organize your browser tabs with one click.",
"description": "Short description for the Chrome Web Store listing."
},
"actionTitle": {
"message": "Open Tab Manager",
"description": "Tooltip for the browser action button."
},
"welcomeMessage": {
"message": "Welcome, $NAME$!",
"description": "Greeting shown to the user on first open.",
"placeholders": {
"name": {
"content": "$1",
"example": "Alex"
}
}
},
"tabCount": {
"message": "You have $COUNT$ tabs open.",
"description": "Tab count label in the popup.",
"placeholders": {
"count": {
"content": "$1",
"example": "12"
}
}
}
}A few rules to know:
- Keys are case-sensitive and must match what you pass to
getMessage(). - Placeholders like
$NAME$are substituted at runtime. Thecontentvalue$1means “the first argument passed togetMessage()”. - The
descriptionfield is never shown to users — it’s purely a hint for translators. Write useful notes here, especially for short strings like button labels. - Every key that exists in your
default_localefile must also exist in all other locale files. Missing keys fall back to the default locale silently, but it’s best to keep them in sync.
For a deeper reference on the format including all placeholder syntax options, see messages.json format explained.
Step 4: use chrome.i18n.getMessage() in your code
In any JavaScript context (background service worker, popup, content script, options page), call chrome.i18n.getMessage(key) to retrieve a localized string:
// popup.js
// Simple string
document.getElementById("title").textContent =
chrome.i18n.getMessage("appName");
// With placeholder substitution
const name = "Alex";
document.getElementById("greeting").textContent =
chrome.i18n.getMessage("welcomeMessage", [name]);
// With multiple placeholders
const count = openTabs.length.toString();
document.getElementById("tabCount").textContent =
chrome.i18n.getMessage("tabCount", [count]);getMessage() returns an empty string "" if the key does not exist in any locale file — not an error. This can cause silent UI issues. Consider a wrapper that throws in development:function t(key, subs) {
const msg = chrome.i18n.getMessage(key, subs);
if (!msg && process.env.NODE_ENV === "development") {
throw new Error(`Missing i18n key: ${key}`);
}
return msg;
}You can also use the API to detect the browser UI language for custom logic:
// Get the browser's display language, e.g. "en-US"
const uiLocale = chrome.i18n.getUILanguage();
// Get the user's accept-language list (returns an array)
chrome.i18n.getAcceptLanguages((languages) => {
console.log(languages); // ["en-US", "fr", "de"]
});Step 5: use __MSG_key__ in manifest.json and CSS
Chrome supports a special substitution syntax outside of JavaScript — the __MSG_key__ pattern. Chrome replaces these tokens at extension load time (not at runtime).
In manifest.json: Use it for any string field that Chrome reads before your JS runs — name, description, action titles, and context menu items:
{
"name": "__MSG_appName__",
"description": "__MSG_appDescription__",
"action": {
"default_title": "__MSG_actionTitle__"
},
"commands": {
"toggle": {
"description": "__MSG_commandToggleDescription__"
}
}
}In CSS files (loaded via content_scripts), you can use the same pattern in content properties:
/* content.css */
.my-extension-tooltip::after {
content: "__MSG_tooltipText__";
}__MSG_key__ substitution does not work in HTML files or in JavaScript. In HTML, use JS to set textContent after load. In JS, use chrome.i18n.getMessage() directly.Step 6: test your localization
Chrome does not switch languages mid-session — it reads the locale at extension load time. To test a specific locale:
- Open Chrome settings (
chrome://settings/languages) and move your target language to the top of the list (or set it as the display language). - Restart Chrome so the new UI language takes effect.
- Load your unpacked extension from
chrome://extensions. Chrome will now load strings from the matching_localessubfolder. - If no matching locale folder exists, Chrome falls back to your
default_localestrings.
--lang flag when launching Chrome from the terminal: # macOS
/Applications/Google\ Chrome.app/Contents/MacOS/Google\ Chrome --lang=de
# Linux
google-chrome --lang=ja
# Windows (PowerShell)
& "C:\Program Files\Google\Chrome\Application\chrome.exe" --lang=frThis overrides the display language for that Chrome instance without changing your system settings.Step 7: translate to other languages
Once your source _locales/en/messages.json is final, you need a messages.json for each target language with the same keys and translated message values. Placeholder tokens ($NAME$) must be preserved exactly — they are not translatable.
A correctly translated _locales/de/messages.json for the example above would look like:
{
"appName": {
"message": "Tab Manager Pro",
"description": "The extension's name as shown in the Chrome Web Store."
},
"appDescription": {
"message": "Organisiere deine Browser-Tabs mit einem Klick.",
"description": "Short description for the Chrome Web Store listing."
},
"actionTitle": {
"message": "Tab-Manager öffnen",
"description": "Tooltip for the browser action button."
},
"welcomeMessage": {
"message": "Willkommen, $NAME$!",
"description": "Greeting shown to the user on first open.",
"placeholders": {
"name": {
"content": "$1",
"example": "Alex"
}
}
},
"tabCount": {
"message": "Du hast $COUNT$ Tabs geöffnet.",
"description": "Tab count label in the popup.",
"placeholders": {
"count": {
"content": "$1",
"example": "12"
}
}
}
}Three things to verify in every translated file:
- All keys from the source file are present (no missing keys).
- All placeholder tokens (
$NAME$,$COUNT$) are present in the translatedmessagestring. - The
placeholdersobject is copied verbatim from the source — its contents are never translated.
Common mistakes
_locales exists but default_locale is missing
Chrome refuses to load the extension with: “Manifest file is invalid”. Both must exist together.
Wrong locale folder name casing
pt-BR or ptBR instead of pt_BR. Chrome silently ignores the folder — the locale never loads and users see your default language.
Translated file is missing keys from the source
Missing keys fall back to the default_locale silently. The extension still loads, but some strings will appear in the wrong language — hard to catch without testing each locale.
Placeholder token dropped or renamed in translation
If a translator removes $NAME$ from the translated message string, the substitution silently disappears from the UI. Always verify placeholders are present in every translated message.
Using __MSG_key__ in HTML or JS
The token substitution only works in manifest.json and CSS files. In HTML, it renders literally as the string __MSG_appName__.
Invalid JSON in a translated file
A single syntax error (missing comma, trailing comma, unescaped quote) in any messages.json file causes Chrome to fail loading that locale silently. Validate JSON before shipping — use JSON.parse() in the browser console or a CI JSON lint step.
What a complete localized extension looks like
After translating to five languages, your extension folder looks like:
my-extension/
├── manifest.json
├── background.js
├── popup.html
├── popup.js
├── options.html
├── icons/
│ └── icon128.png
└── _locales/
├── en/
│ └── messages.json ← source (English)
├── de/
│ └── messages.json
├── fr/
│ └── messages.json
├── es/
│ └── messages.json
├── ja/
│ └── messages.json
└── pt_BR/
└── messages.jsonWhen you submit to the Chrome Web Store, include the entire _locales folder in your ZIP. The Store automatically displays your extension name and description in the visitor’s language, improving discoverability for non-English speakers.
Skip the manual translation step
Once your source messages.json is ready, LocalePack translates it into up to 52 languages, preserves every $PLACEHOLDER$ token, and delivers a ready-to-extract _locales ZIP with correctly named folders. Upload, pay once, download — no subscription, no platform setup.