Plurals and Placeholders in messages.json

Handle plurals and placeholders correctly in an extension's messages.json: named placeholders with $1 substitutions, why chrome.i18n has no plural rules, using Intl.PluralRules with suffixed keys, and keeping translators informed.

Published October 2, 2026 Updated October 2, 2026 7 min read
Table of Contents

The badge tooltip says “1 items blocked” in English, and in Polish it is wrong in three different ways depending on the number. The extension’s messages.json has one itemsBlocked string with a $COUNT$ placeholder, and chrome.i18n.getMessage does exactly what it says — substitutes the number — but it has no idea that English has two plural forms, Polish has four, and Japanese has one. Placeholders and plurals are where most extension localisation goes wrong, because the chrome.i18n API is deliberately simple and the plural logic has to be built on top. This guide shows the placeholder syntax properly and adds plural support with Intl.PluralRules. It belongs to internationalization and accessibility.

What chrome.i18n does and does not do

chrome.i18n.getMessage(name, substitutions) looks up name in the _locales/<locale>/messages.json for the browser’s UI language, falling back to default_locale, and replaces $1…$9 in the message’s placeholders with the substitution strings. Placeholders are declared per message: the message text uses named tokens like $COUNT$, and a placeholders object maps each name to content such as "$1", with an optional example for translators. That is the whole feature — there is no plural selection, no gender, no number formatting. Languages differ in how many plural categories they use (zero, one, two, few, many, other in CLDR), so a single string with a number in it is wrong somewhere. The standard fix is one message per plural category, chosen at runtime with Intl.PluralRules.

From count to correct sentenceA count is passed to Intl.PluralRules for the UI language, which returns a category such as one, few or other; the code builds a key like itemsBlocked_few, looks it up with chrome.i18n.getMessage, and substitutes the formatted number into the placeholder.count = 3raw numberPluralRules(uiLang)select(3)"few"CLDR categorykey = itemsBlocked_fewgetMessage(key, [n])locale lookupNumberFormat"3" or "1 234""Zablokowano 3 elementy"correct form
Plural category first, then message lookup, then substitution.

Step-by-step: correct placeholders and plurals

1. Declare named placeholders with examples

 1// _locales/en/messages.json
 2{
 3  "savedTo": {
 4    "message": "Saved \"$TITLE$\" to $FOLDER$",
 5    "description": "Toast after saving a page. TITLE is the page title, FOLDER the folder name.",
 6    "placeholders": {
 7      "title":  { "content": "$1", "example": "How to bake bread" },
 8      "folder": { "content": "$2", "example": "Recipes" }
 9    }
10  }
11}

Execution context: the extension’s locale files. Placeholder names are case-insensitive in the message ($TITLE$ matches title), content maps them to positional substitutions, and example is shown to translators in many tools. Named placeholders let translators reorder words — Japanese might put the folder first — without touching code. The description field is the single most useful thing for translation quality.

2. Call getMessage with substitutions

1const msg = chrome.i18n.getMessage("savedTo", [page.title, folder.name]);

Execution context: any extension context, including content scripts. Substitutions must be strings; pass at most nine. The function returns an empty string for a missing key rather than throwing, so wrap it in a helper that logs missing keys in development.

Plural categories by languageWhich CLDR plural categories English, French, Polish, Arabic and Japanese use for cardinal numbers.LanguageonefewmanyotherEnglish1——0, 2+French0, 1—1 000 000…2+Polish12–4, 22–24…5–21…fractionsArabic13–1011–99100+ (+zero, two)Japanese———all
Never assume two forms — ask Intl.PluralRules.

3. Add one key per plural category

1// _locales/en/messages.json
2"itemsBlocked_one":   { "message": "$N$ item blocked",  "placeholders": { "n": { "content": "$1", "example": "1" } } },
3"itemsBlocked_other": { "message": "$N$ items blocked", "placeholders": { "n": { "content": "$1", "example": "12" } } }
4
5// _locales/pl/messages.json
6"itemsBlocked_one":   { "message": "Zablokowano $N$ element" },
7"itemsBlocked_few":   { "message": "Zablokowano $N$ elementy" },
8"itemsBlocked_many":  { "message": "Zablokowano $N$ elementów" },
9"itemsBlocked_other": { "message": "Zablokowano $N$ elementu" }

Execution context: locale files (placeholders abbreviated in the Polish file for space; each key needs its own placeholders block). Each locale provides only the categories its language uses, plus other, which every language has. Message names allow letters, digits and underscores, so _one, _few suffixes work as keys.

4. Pick the category at runtime

 1// i18n.js
 2const uiLang = chrome.i18n.getUILanguage();
 3const pluralRules = new Intl.PluralRules(uiLang);
 4const numberFmt = new Intl.NumberFormat(uiLang);
 5
 6export function plural(base, count) {
 7  const cat = pluralRules.select(count);
 8  const n = numberFmt.format(count);
 9  return chrome.i18n.getMessage(`${base}_${cat}`, [n])
10      || chrome.i18n.getMessage(`${base}_other`, [n]);
11}
12// plural("itemsBlocked", 3) → "Zablokowano 3 elementy" in Polish

Execution context: any extension page, content script or the service worker — Intl is available everywhere. Use chrome.i18n.getUILanguage() so the plural rules match the locale getMessage uses, not the page’s language. The _other fallback covers a locale that is missing a category. Formatting the number with Intl.NumberFormat gives “1 234” or “1,234” as the locale expects; see formatting dates and numbers with Intl.

Choosing the message strategyDecision tree for a UI string: no variable parts use a plain message; variable text uses named placeholders; a count uses plural keys with Intl.PluralRules; a count of zero with special meaning gets its own key.What varies in the string?nothingPlain messagegetMessage(key)names, titlesNamed placeholders$TITLE$ → $1a countPlural keys_one, _few, _otherSpecial zero?add _zero key
Counts always need plural keys, even in English-only builds you plan to translate.

5. Treat zero as a distinct state

1export function blockedLabel(count) {
2  if (count === 0) return chrome.i18n.getMessage("itemsBlocked_none");   // "Nothing blocked on this page"
3  return plural("itemsBlocked", count);
4}

Execution context: UI code. “0 items blocked” is grammatical but rarely the best copy — a zero state usually deserves its own sentence. Use an explicit _none key rather than relying on the CLDR zero category, which only a few languages (such as Arabic and Latvian) use.

6. Localise HTML pages declaratively

1<span data-i18n="savedTo" data-i18n-args='["Recipes","Bread"]'></span>
2<script type="module">
3  for (const el of document.querySelectorAll("[data-i18n]")) {
4    const args = JSON.parse(el.dataset.i18nArgs ?? "[]");
5    el.textContent = chrome.i18n.getMessage(el.dataset.i18n, args);
6  }
7</script>

Execution context: extension pages. Setting textContent (not innerHTML) keeps substituted values — often page titles from the web — from injecting markup. The CSS __MSG_name__ syntax works only in CSS and manifest files, not in HTML, which is why pages need a small script like this. See localising extension UI with the i18n API.

7. Lint locale files in CI

 1// scripts/check-locales.mjs
 2const en = JSON.parse(await readFile("_locales/en/messages.json", "utf8"));
 3for (const loc of await readdir("_locales")) {
 4  const m = JSON.parse(await readFile(`_locales/${loc}/messages.json`, "utf8"));
 5  const cats = new Intl.PluralRules(loc.replace("_", "-")).resolvedOptions().pluralCategories;
 6  for (const base of pluralBases(en)) {
 7    for (const c of cats) if (!m[`${base}_${c}`]) console.warn(`${loc}: missing ${base}_${c}`);
 8  }
 9  for (const [k, v] of Object.entries(m)) {
10    for (const ph of v.message.match(/\$[A-Z_]+\$/gi) ?? []) {
11      if (!v.placeholders?.[ph.slice(1, -1).toLowerCase()]) throw new Error(`${loc}/${k}: undeclared ${ph}`);
12    }
13  }
14}

Execution context: Node in CI. resolvedOptions().pluralCategories tells you exactly which keys each locale needs. Checking that every $NAME$ token has a declared placeholder catches a common translator error that otherwise shows literally as “$NAME$” in the UI.

8. Give translators context

Every message with a placeholder or plural should have a description explaining what the variables are, where the string appears, and any length limit (badge tooltips and context menu titles are short). Translation platforms such as Crowdin and Weblate understand Chrome’s messages.json format and show the description and placeholder examples.

Common mistakes

  • One string for all counts. “1 items” in English, worse elsewhere.
  • Building sentences by concatenation. getMessage("saved") + " " + title breaks word order.
  • Using the page’s language for plural rules. Use getUILanguage().
  • Undeclared placeholders. The literal $NAME$ appears in the UI.
  • No descriptions. Translators guess, and guess wrong.

Cross-browser variation

  • Chrome / Edge: chrome.i18n as described; Intl.PluralRules available in all contexts.
  • Firefox: same messages.json format and substitution rules; browser.i18n.getUILanguage() returns the Firefox UI locale.
  • Safari: supports _locales and getMessage; the containing app’s localisation is separate, in Xcode string catalogs.

Verification

  1. Launch the browser with --lang=pl and check counts 1, 2, 5, 22 and 1.5 against a native speaker’s expectations.
  2. Remove a plural key from a locale and confirm the _other fallback appears rather than an empty string.
  3. Run the locale lint and confirm it flags missing categories.
  4. Substitute a title containing <b> and confirm it renders as text.

FAQ

Can I nest placeholders or use ICU MessageFormat?

Not with chrome.i18n. If you need ICU syntax, use a library such as @formatjs/intl with its own message files, and keep messages.json for the manifest and store fields.

Why does getMessage return an empty string?

The key is missing in both the current and default locale, or the name has a typo. Message names are case-insensitive but must match exactly otherwise.

Can I switch the extension’s language independently of the browser?

chrome.i18n always follows the browser UI language. A language picker requires loading message files yourself with fetch(chrome.runtime.getURL(...)).

How many placeholders can one message have?

Up to nine positional substitutions ($1–$9). Messages needing more should be split.

Other UI/UX Patterns & Interactive Components Resources