LocalePack
ChromeFirefoxEdgeOperaSafariCWS Listing
Vue.jsReact
Next.jsi18nextReact Native
Guides
Home/Guides/Validate messages.json
March 6, 2026

Validate messages.json before shipping

A single error in any messages.json file — a missing comma, a wrong placeholder name, a key that exists in English but not in German — can silently break your extension for an entire locale. None of these errors throw at runtime; Chrome and Firefox just return empty strings or fall back silently. Here is how to catch every class of error before your users do.

Why validation is easy to skip — and why you shouldn’t

Most i18n bugs in browser extensions are silent. The extension loads, the UI renders, and everything looks fine — until a German-speaking user opens the popup and sees an empty button label. Or a Japanese user gets an English string where the translated one should appear. Or the Chrome Web Store rejects your upload because a __MSG_key__ in your manifest references a key that doesn’t exist in your default_locale file.

There are four distinct categories of messages.json errors, each with different failure modes:

1

JSON syntax errors

fails loudly

Trailing commas, unescaped quotes, or missing braces prevent the entire locale from loading.

2

Missing required fields

silent failure

Every message entry must have a message field. Missing it causes getMessage() to return an empty string.

3

Missing keys across locales

silent failure

A key in your default locale that is absent from a translated locale silently falls back to English.

4

Malformed placeholder syntax

silent failure

A renamed or dropped $PLACEHOLDER$ token causes the substitution to disappear entirely from the UI.

1. JSON syntax errors

This is the only class of error that fails loudly. messages.json must be valid JSON — no trailing commas, no comments, no single-quoted strings. A syntax error in any locale file prevents the entire locale from loading. Chrome and Firefox silently fall back to default_locale when a locale file is broken, so the user gets English strings with no indication that something is wrong.

Common syntax mistakes:

// ✗ Trailing comma after the last key
{
  "appName": {
    "message": "My Extension"
  },   ← remove this comma
}

// ✗ Unescaped double quote inside a string
{
  "tooltip": {
    "message": "Click \"here\" to continue"   ← must escape with backslash
  }
}

// ✗ Single-quoted strings (not valid JSON)
{
  'appName': {
    'message': 'My Extension'   ← JSON requires double quotes
  }
}

How to catch them:

# Node.js — parse each locale file
node -e "JSON.parse(require('fs').readFileSync('_locales/de/messages.json','utf8'))"

# Shell — check all locale files at once
for f in _locales/*/messages.json; do
  node -e "JSON.parse(require('fs').readFileSync('$f','utf8'))" && echo "OK: $f" || echo "ERROR: $f"
done
Add this check as a pre-build or pre-publish step. If the command exits non-zero, the build fails before anything reaches the Chrome Web Store or AMO.

2. Missing required fields

Each entry in messages.json must have a message field. If the field is absent, getMessage() returns an empty string — not an error — for every call using that key.

// ✗ Missing message field — getMessage("appName") returns ""
{
  "appName": {
    "description": "The extension name"
  }
}

// ✓ Correct
{
  "appName": {
    "message": "My Extension",
    "description": "The extension name"
  }
}

Validate all entries have a message field with a script:

// validate-messages.mjs
import { readdirSync, readFileSync } from "node:fs";
import { join } from "node:path";

const localesDir = "_locales";
let hasErrors = false;

for (const locale of readdirSync(localesDir)) {
  const filePath = join(localesDir, locale, "messages.json");
  const messages = JSON.parse(readFileSync(filePath, "utf8"));

  for (const [key, value] of Object.entries(messages)) {
    if (!value.message && value.message !== "") {
      console.error(`[${locale}] "${key}" is missing the "message" field`);
      hasErrors = true;
    }
  }
}

if (hasErrors) process.exit(1);
else console.log("All message fields present ✓");

3. Missing keys across locales

This is the most common production bug in localized extensions. Your source file (the default_locale messages.json) has 40 keys. A translated file has 37. The 3 missing keys silently fall back to English. No error, no warning — your German users just see 3 English strings.

The reverse also matters: extra keys in a translated file that don’t exist in the source are dead weight — they can never be reached and make the file harder to maintain.

// validate-keys.mjs — check all locales against the default
import { readdirSync, readFileSync } from "node:fs";
import { join } from "node:path";

const DEFAULT_LOCALE = "en"; // change to your default_locale value
const localesDir = "_locales";

const sourceFile = join(localesDir, DEFAULT_LOCALE, "messages.json");
const sourceKeys = new Set(Object.keys(JSON.parse(readFileSync(sourceFile, "utf8"))));

let hasErrors = false;

for (const locale of readdirSync(localesDir)) {
  if (locale === DEFAULT_LOCALE) continue;

  const filePath = join(localesDir, locale, "messages.json");
  const translatedKeys = new Set(Object.keys(JSON.parse(readFileSync(filePath, "utf8"))));

  // Keys in source but missing from translation
  for (const key of sourceKeys) {
    if (!translatedKeys.has(key)) {
      console.error(`[${locale}] missing key: "${key}"`);
      hasErrors = true;
    }
  }

  // Keys in translation not in source (orphaned)
  for (const key of translatedKeys) {
    if (!sourceKeys.has(key)) {
      console.warn(`[${locale}] extra key (not in source): "${key}"`);
    }
  }
}

if (hasErrors) process.exit(1);
else console.log("All locale keys match ✓");
Run this script after every translation update. A new key added to the source file must be present in every locale file before shipping. Missing keys only show up when a user with that specific locale opens your extension — not in your own testing if you develop in English.

4. Malformed placeholder syntax

Placeholders are the trickiest part of messages.json to validate. The format requires that every $NAME$ token in the message string has a matching lowercase key in the placeholders object, and each placeholder must have a content field. See messages.json format explained for the full placeholder reference.

The four placeholder mistakes that cause silent failures:

Token in message with no matching placeholder definition

If $USER$ appears in message but there is no user key in placeholders, Chrome outputs the literal string $USER$ in the UI.

Placeholder definition with no matching token in message

A placeholder entry defined in the placeholders object but never used in the message string is harmless but should be treated as a warning — it's likely a copy-paste leftover.

Placeholder renamed by a translator

A translator changes $USER$ to $BENUTZER$ in the German file. The browser now looks for a benutzer key, finds nothing, and outputs the literal token.

Missing content field in a placeholder definition

Each entry in placeholders must have a content field specifying the positional argument ($1) or a literal value. Without it, the token always resolves to an empty string.

Script to validate placeholder consistency:

// validate-placeholders.mjs
import { readdirSync, readFileSync } from "node:fs";
import { join } from "node:path";

const localesDir = "_locales";
const TOKEN_RE = /\$([A-Z0-9_]+)\$/g;
let hasErrors = false;

for (const locale of readdirSync(localesDir)) {
  const filePath = join(localesDir, locale, "messages.json");
  const messages = JSON.parse(readFileSync(filePath, "utf8"));

  for (const [key, entry] of Object.entries(messages)) {
    if (!entry.message) continue;

    const tokensInMessage = [...entry.message.matchAll(TOKEN_RE)].map(m => m[1].toLowerCase());
    const definedPlaceholders = Object.keys(entry.placeholders ?? {});

    for (const token of tokensInMessage) {
      if (!definedPlaceholders.includes(token)) {
        console.error(`[${locale}] "${key}": token $${token.toUpperCase()}$ has no placeholder definition`);
        hasErrors = true;
      }
    }

    for (const placeholder of definedPlaceholders) {
      if (!tokensInMessage.includes(placeholder)) {
        console.warn(`[${locale}] "${key}": placeholder "${placeholder}" defined but not used in message`);
      }
    }

    for (const [name, def] of Object.entries(entry.placeholders ?? {})) {
      if (!def.content) {
        console.error(`[${locale}] "${key}": placeholder "${name}" is missing the "content" field`);
        hasErrors = true;
      }
    }
  }
}

if (hasErrors) process.exit(1);
else console.log("All placeholders valid ✓");

5. Manifest errors caused by messages.json

Your manifest.json can reference keys from messages.json via the __MSG_key__ syntax. If any referenced key is missing from your default_locale file, the Chrome Web Store will reject the upload. See default_locale rules and common errors for the full error list.

__MSG_appName__ in manifest but no appName key in default_locale

Chrome Web Store rejects upload: key not found in catalog

_locales/ exists but default_locale missing from manifest

Extension fails to load: "default_locale is required"

default_locale value uses a hyphen (e.g. en-US)

Extension fails to load: "Invalid locale code"

default_locale folder absent from _locales/

Extension fails to load: "Catalog file is missing"

Check that all __MSG_key__ references in the manifest exist in your source file:

// validate-manifest-keys.mjs
import { readFileSync } from "node:fs";
import { join } from "node:path";

const manifest = JSON.parse(readFileSync("manifest.json", "utf8"));
const defaultLocale = manifest.default_locale;

if (!defaultLocale) {
  console.log("No default_locale — skipping manifest key check");
  process.exit(0);
}

const sourceFile = join("_locales", defaultLocale, "messages.json");
const sourceKeys = new Set(Object.keys(JSON.parse(readFileSync(sourceFile, "utf8"))));

const manifestStr = JSON.stringify(manifest);
const MSG_RE = /__MSG_([A-Za-z0-9_@]+)__/g;
let hasErrors = false;

for (const [, key] of manifestStr.matchAll(MSG_RE)) {
  if (!sourceKeys.has(key)) {
    console.error(`manifest.json: __MSG_${key}__ not found in ${defaultLocale}/messages.json`);
    hasErrors = true;
  }
}

if (hasErrors) process.exit(1);
else console.log("All manifest __MSG_ references are valid ✓");

6. Duplicate keys

JSON parsers handle duplicate keys by keeping the last value and silently discarding earlier ones. A file with a duplicate key is valid JSON and will parse without error — but the first definition is invisible at runtime.

// ✗ Duplicate key — only "Something Else" is ever returned
{
  "appName": { "message": "My Extension" },
  "appName": { "message": "Something Else" }
}

// Check for duplicates in all locale files
// validate-duplicates.mjs
import { readdirSync, readFileSync } from "node:fs";
import { join } from "node:path";

function findDuplicateKeys(jsonStr) {
  const seen = new Set();
  const duplicates = [];
  for (const [, key] of jsonStr.matchAll(/"([^"]+)"\s*:/g)) {
    if (seen.has(key)) duplicates.push(key);
    seen.add(key);
  }
  return duplicates;
}

let hasErrors = false;

for (const locale of readdirSync("_locales")) {
  const filePath = join("_locales", locale, "messages.json");
  const content = readFileSync(filePath, "utf8");
  const dups = findDuplicateKeys(content);

  if (dups.length > 0) {
    console.error(`[${locale}] duplicate keys: ${dups.join(", ")}`);
    hasErrors = true;
  }
}

if (hasErrors) process.exit(1);
else console.log("No duplicate keys ✓");

Putting it all together: a complete validation pipeline

Run all checks in sequence as part of your build. Add a prebuild script so any CI pipeline or pre-publish hook catches them automatically:

// package.json
{
  "scripts": {
    "validate:json":         "node validate-messages.mjs",
    "validate:keys":         "node validate-keys.mjs",
    "validate:placeholders": "node validate-placeholders.mjs",
    "validate:manifest":     "node validate-manifest-keys.mjs",
    "validate:duplicates":   "node validate-duplicates.mjs",
    "validate": "npm run validate:json && npm run validate:keys && npm run validate:placeholders && npm run validate:manifest && npm run validate:duplicates",
    "prebuild": "npm run validate"
  }
}
With prebuild set, running npm run build automatically validates all locale files first. A failed validation exits non-zero and stops the build before anything is packaged or uploaded.

Quick validation checklist before every release

CheckFailure mode
All locale files parse as valid JSONLocale silently falls back to default_locale
Every entry has a "message" fieldgetMessage() returns empty string
All keys in source exist in every translated fileMissing keys fall back to source language
No extra keys in translated files not in sourceDead strings — unreachable, maintenance burden
Every $TOKEN$ in message has a matching placeholder definitionToken rendered literally in the UI
Every placeholder definition has a content fieldSubstitution always resolves to empty string
Placeholder tokens unchanged in translated filesToken rendered literally; substitution lost
No duplicate keys in any locale fileFirst definition silently overwritten
All __MSG_key__ in manifest exist in default_locale fileWeb Store upload rejected
default_locale value matches a folder in _locales/Extension fails to load: "Catalog file is missing"

Let LocalePack validate for you

LocalePack validates your source messages.json on upload — catching JSON errors, missing message fields, and malformed placeholders before translation begins. The output ZIP preserves every $PLACEHOLDER$ token exactly, so translated files pass placeholder validation automatically. Upload once, pay once, download a ready _locales ZIP.

Translate my validated messages.json →
← Back to Guides
LocalePack
GuidesPrivacyTermsSupport

© 2025 LocalePack. All rights reserved.

This project was translated with LocalePack logoLocalePack