messages.json format explained (with placeholders)
The messages.json file is the core of browser extension i18n. Every Chrome, Firefox, Edge, Safari, and Opera extension that supports multiple languages uses this format. Here is exactly how it works — including the parts that trip up most developers.
File location
Each locale gets its own messages.json file inside a _locales folder in the extension root:
my-extension/
├── manifest.json
├── _locales/
│ ├── en/
│ │ └── messages.json ← default locale
│ ├── de/
│ │ └── messages.json
│ ├── fr/
│ │ └── messages.json
│ └── ja/
│ └── messages.json
└── ...The default_locale field in manifest.json tells the browser which folder is the fallback when a user’s locale is not available:
{
"manifest_version": 3,
"name": "__MSG_appName__",
"default_locale": "en",
...
}_locales directory exists, you must declare default_locale. Omitting it causes the extension to be rejected by the Chrome Web Store and Firefox AMO.The three fields of a message entry
Each key in messages.json maps to an object with up to three fields:
{
"appName": {
"message": "My Extension",
"description": "The name of the extension shown in the browser UI.",
"placeholders": {}
}
}messagerequiredThe actual string that chrome.i18n.getMessage() returns. This is the only required field. It is the text shown to the user.
descriptionoptionalA hint for translators explaining what the string is used for. It is never shown to end users. LocalePack reads this field to give the AI more context, which improves translation accuracy.
placeholdersoptionalAn object defining named dynamic values that appear in the message string. Defined placeholders survive translation untouched.
Placeholders: $PLACEHOLDER$ syntax
When a string contains dynamic values — a user name, a count, a URL — you define them as placeholders rather than embedding them literally. This prevents the AI (or human translator) from accidentally altering the variable name.
{
"welcomeMessage": {
"message": "Welcome, $USER$! You have $COUNT$ new messages.",
"description": "Greeting shown on the dashboard after login.",
"placeholders": {
"user": {
"content": "$1",
"example": "Alice"
},
"count": {
"content": "$2",
"example": "3"
}
}
}
}The key rules for placeholder names:
- •In the
messagestring, placeholders are referenced as$NAME$— always uppercase with dollar signs on both sides. - •In the
placeholdersobject, the key is the same name but lowercase (e.g.userfor$USER$). - •The
contentfield specifies how the placeholder value is supplied at runtime:$1means the first argument togetMessage(),$2the second, and so on (up to$9). - •The
examplefield is optional but helps translators understand what the placeholder represents.
Calling getMessage() with substitutions
Pass substitution values as the second argument to chrome.i18n.getMessage() (or browser.i18n.getMessage() in Firefox/Safari). The second argument is an array of strings mapped to $1, $2, etc.:
// Chrome / Edge / Opera
const msg = chrome.i18n.getMessage("welcomeMessage", ["Alice", "3"]);
// → "Welcome, Alice! You have 3 new messages."
// Firefox / Safari
const msg = browser.i18n.getMessage("welcomeMessage", ["Alice", "3"]);
// → "Welcome, Alice! You have 3 new messages."Placeholders without positional substitution
Sometimes the placeholder value is a literal string that never changes — for example, a brand name or URL you want to keep in every locale. Set content to the literal string instead of $1:
{
"openDashboard": {
"message": "Open $PRODUCT_NAME$ dashboard",
"description": "Button label for the main dashboard link.",
"placeholders": {
"product_name": {
"content": "LocalePack"
}
}
}
}In this case getMessage("openDashboard") returns "Open LocalePack dashboard" — no substitution array needed. The brand name is baked in and safe from translation.
Common mistakes
Translating the placeholder name
A translator changes $USER$ to $BENUTZER$ in the German file. The browser looks for a benutzer key in placeholders, finds nothing, and outputs $BENUTZER$ literally. Always preserve placeholder tokens verbatim. LocalePack does this automatically.
Missing default_locale
Creating a _locales directory without declaring default_locale in manifest.json. Chrome will refuse to load the extension and the Web Store will reject the upload.
Using locale codes not in the allowed list
Chrome only accepts the locale codes listed in its documentation (e.g. pt_BR, zh_CN). A folder named zh-Hant or pt-BR (hyphen instead of underscore) will be silently ignored.
Forgetting trailing comma in older JSON
messages.json must be valid JSON. Trailing commas after the last key are invalid JSON and will prevent the extension from loading. Use a JSON linter or validator before shipping.
Duplicate keys
JSON parsers typically use the last value for duplicate keys, silently discarding earlier ones. This makes duplicate keys hard to detect. LocalePack validates your source file and flags duplicates before translation.
Full example: a complete messages.json
{
"appName": {
"message": "My Extension",
"description": "The name of the extension shown in browser UI and the Web Store."
},
"appDescription": {
"message": "Boost your productivity with one click.",
"description": "Short description shown in browser UI and Web Store listings."
},
"welcomeMessage": {
"message": "Welcome, $USER$!",
"description": "Greeting shown after the user signs in.",
"placeholders": {
"user": {
"content": "$1",
"example": "Alice"
}
}
},
"itemCount": {
"message": "You have $COUNT$ items in your list.",
"description": "Status message showing how many items the user has saved.",
"placeholders": {
"count": {
"content": "$1",
"example": "5"
}
}
},
"openDocs": {
"message": "Open $PRODUCT$ documentation",
"description": "Link label for the documentation page.",
"placeholders": {
"product": {
"content": "My Extension"
}
}
}
}Need to translate your messages.json into 52 languages?
LocalePack reads your messages.json, uses the description field as translation context, and preserves every $PLACEHOLDER$ exactly as-is. Download a ready-to-ship _locales ZIP in minutes. Pay once, no subscription.