Browser extension i18n without a TMS subscription
Translation management systems — Crowdin, Lokalise, Phrase, Localazy — are built for software teams shipping dozens of locales across multiple products on a continuous release cycle. If you are an indie developer or small team with a browser extension and a single messages.json file, a TMS subscription is almost certainly overkill. Here is why — and what to do instead.
What a TMS actually requires you to do
Every major TMS follows the same onboarding flow. Before you can translate a single string, you must:
Create an account and verify your email
All TMS platforms require account registration. Some also require a work email or company domain.
Create a project and configure it
Set a source language, add target languages, choose a file format (Chrome JSON, WebExtension JSON, etc.), and configure branch or version settings.
Integrate your repository or upload files manually
Most TMS platforms push you toward a GitHub or GitLab integration that syncs locale files automatically. Setting this up requires a bot token, a YAML config file, and typically a CI step.
Invite collaborators or configure machine translation
Without human translators, you configure an MT engine — DeepL, Google Translate, or similar — through another integration step.
Start a subscription
Free tiers on Crowdin, Lokalise, and Phrase cover only a limited number of strings or source words before they require a paid plan.
For a team shipping a complex SaaS product with continuous deployments and multiple translators, this overhead is justified. For a developer with a 60-key messages.json who wants to add 10 languages before a Chrome Web Store update, it is a multi-hour setup with a monthly recurring cost.
What TMS platforms cost
Published pricing as of early 2026. All plans require a monthly or annual subscription — there is no pay-per-translation option on any major TMS.
| Platform | Free tier | Paid entry point | Setup required |
|---|---|---|---|
| Crowdin | Open-source projects only | ~$50/month (Basic) | Account + project + GitHub integration |
| Lokalise | 14-day trial only | ~$120/month (Start) | Account + project + API key or GitHub sync |
| Phrase | 14-day trial only | ~$30/month (Starter, limited keys) | Account + project + format config |
| Localazy | Up to 1,000 source keys | ~$20/month (Startup) | Account + project + CLI or GitHub integration |
| Weblate | Self-hosted (free); cloud has free tier | ~$30/month (Basic cloud) | Account + project + VCS integration |
Verify current pricing on each platform before purchasing.
What the extension i18n format actually looks like
The WebExtension i18n API reads from _locales/{locale}/messages.json. Each key maps to a message object with a message field, an optional description, and optional placeholders:
// _locales/en/messages.json
{
"appName": {
"message": "Tab Manager Pro",
"description": "The extension name shown in the browser"
},
"welcomeMessage": {
"message": "Welcome, $USER$",
"description": "Greeting shown on first open",
"placeholders": {
"user": {
"content": "$1",
"example": "Alice"
}
}
},
"tabCount": {
"message": "$COUNT$ tabs open",
"placeholders": {
"count": { "content": "$1" }
}
}
}A translated file keeps the same structure. Key names and $PLACEHOLDER$ tokens must not change — only the message value changes:
// _locales/de/messages.json
{
"appName": {
"message": "Tab Manager Pro",
"description": "The extension name shown in the browser"
},
"welcomeMessage": {
"message": "Willkommen, $USER$",
"description": "Greeting shown on first open",
"placeholders": {
"user": {
"content": "$1",
"example": "Alice"
}
}
},
"tabCount": {
"message": "$COUNT$ Tabs geöffnet",
"placeholders": {
"count": { "content": "$1" }
}
}
}This is a straightforward JSON-to-JSON transformation. The structure stays the same; only the string values inside message fields change, with $PLACEHOLDER$ tokens preserved exactly. A TMS is not required for this transformation.
The three real options for indie extension developers
Option A
Machine translate manually with the DeepL or Google Translate API
Advantages
- +No subscription within the free tier (DeepL: 500k chars/month, Google: 500k chars/month)
- +Scriptable — write a one-time Node.js script and run it locally
- +Full control over output
Trade-offs
- –You write and maintain the script yourself
- –Placeholder tokens ($USER$, $COUNT$) must be handled carefully — naive translation corrupts them
- –No structured output — you assemble the _locales ZIP yourself
Option B
Use a free-tier TMS and stay within the string limit
Advantages
- +Localazy: up to 1,000 source keys free
- +GUI for reviewing translations before download
Trade-offs
- –Account creation and project setup required
- –Free tier limits are per-project and per-account
- –Crowdin and Lokalise free tiers are for open-source projects only
- –Platform lock-in — your locale files live in their system
Option C
Upload → pay once → download a ready _locales ZIP
Advantages
- +No account, no project setup, no subscription
- +Upload your messages.json, select target languages, pay a flat fee
- +Download a ZIP with the correct _locales/{lang}/messages.json structure
- +Placeholder tokens ($PLACEHOLDER$) are preserved automatically
- +Output passes validation and is ready to drop into your extension
Trade-offs
- –Pay per translation job — not ideal for teams with continuous daily updates
- –No translation memory or glossary (suitable for one-shot or infrequent jobs)
When a TMS is the right choice
TMS platforms exist for good reasons. They are the right tool when:
You have human translators
TMS platforms are built around translator workflows — assignment, review, approval, and glossary enforcement. If your localization involves human translators rather than MT, a TMS gives them the right interface.
You ship continuous updates across many languages
If your extension updates strings every week and you maintain 20+ locales, TMS integration pays off. The GitHub sync workflow detects new strings and queues them automatically.
You have a translation memory requirement
TMS platforms remember previous translations and reuse them automatically. For large catalogs where the same phrasing appears in multiple places, this saves cost and ensures consistency.
You manage multiple products on the same platform
If you have a Chrome extension, a Firefox add-on, a web app, and a mobile app sharing overlapping strings, a TMS lets you manage all of them with shared glossaries.
If none of these apply — if you are a solo developer shipping an extension update and need 10 locale files this week — a TMS subscription is not the right tool.
The DIY approach: translate messages.json with the DeepL API
If you want to own the full translation pipeline, here is a minimal Node.js script that translates a messages.json file using the DeepL API while preserving $PLACEHOLDER$ tokens. DeepL supports XML tag protection — wrap tokens before sending and unwrap after:
// translate-extension.mjs
// Requires: npm install deepl-node
// Uses the DeepL free API tier (500k chars/month)
import * as deepl from "deepl-node";
import { readFileSync, writeFileSync, mkdirSync } from "node:fs";
import { join } from "node:path";
const DEEPL_KEY = process.env.DEEPL_API_KEY;
const TARGET_LANGS = ["DE", "FR", "JA", "ES", "PT-BR", "ZH"];
const translator = new deepl.Translator(DEEPL_KEY);
const source = JSON.parse(readFileSync("_locales/en/messages.json", "utf8"));
const TOKEN_RE = /\$([A-Z0-9_]+)\$/g;
async function translateEntry(messageStr, targetLang) {
// Wrap $TOKEN$ as <keep>TOKEN</keep> so DeepL ignores it
const wrapped = messageStr.replace(TOKEN_RE, (_, name) => `<keep>${name}</keep>`);
const result = await translator.translateText(wrapped, "en", targetLang, {
tagHandling: "xml",
ignoreTags: ["keep"],
});
// Restore $TOKEN$ from <keep>TOKEN</keep>
return result.text.replace(/<keep>([^<]+)<\/keep>/g, (_, name) => `$${name}$`);
}
for (const lang of TARGET_LANGS) {
const output = {};
for (const [key, entry] of Object.entries(source)) {
const translated = await translateEntry(entry.message, lang);
output[key] = { ...entry, message: translated };
}
const dir = join("_locales", lang.toLowerCase().replace("-", "_"));
mkdirSync(dir, { recursive: true });
writeFileSync(join(dir, "messages.json"), JSON.stringify(output, null, 2));
console.log(`Written: ${dir}/messages.json`);
}$PLACEHOLDER$ tokens are intact in the output files. A typical extension messages.json with 80 keys across 10 languages is around 8,000–15,000 characters — well within the DeepL free tier of 500,000 characters per month.What to check before shipping translated files
Whether you translate manually, with a script, or with a service, run these checks before packaging your extension:
| Check | Why it matters |
|---|---|
| All locale files parse as valid JSON | A JSON syntax error silently falls back to the default_locale — broken locale, no error shown |
| Every entry has a "message" field | chrome.i18n.getMessage() returns an empty string for entries missing the message field |
| All keys from the source file exist in every translated file | Missing keys silently fall back to the source language string |
| $PLACEHOLDER$ tokens unchanged in translated files | A renamed or translated token causes the browser to render the literal token text in the UI |
| Locale folder names use underscores (pt_BR, zh_CN) | Hyphenated folder names (pt-BR) cause the extension to fail to load |
| default_locale in manifest.json matches a _locales/ folder | Extension fails to load: "Catalog file is missing for locale" |
See Validate messages.json before shipping for ready-to-run validation scripts covering all of these checks.
The _locales folder structure
Chrome, Firefox, Edge, Safari, and Opera all expect the same folder layout. Locale codes use underscores, not hyphens (pt_BR not pt-BR). The default_locale in manifest.json must match one of the folder names exactly:
_locales/
en/
messages.json ← default_locale
de/
messages.json
fr/
messages.json
ja/
messages.json
es/
messages.json
pt_BR/ ← underscore, not hyphen
messages.json
zh_CN/
messages.json
manifest.json
"default_locale": "en" ← must match a _locales/ folder nameSee Chrome extension i18n: _locales structure and default_locale for the full reference including supported locale codes.
Summary: which approach fits your situation
Solo or small team, extension with fewer than 200 keys, infrequent updates
Upload → pay once → download. No setup, no subscription.
Comfortable with Node.js, want full control, within DeepL free tier
DIY script with deepl-node and XML tag protection for placeholders.
Open-source extension, want community translators
Crowdin or Lokalise — both have free tiers for open-source projects and community translation workflows.
Team shipping weekly updates across 20+ locales with human translators
A TMS subscription is justified. Evaluate Lokalise, Phrase, or Crowdin based on file format support and translator UX.
Translate your extension without a subscription
LocalePack is the upload → pay once → download option. Upload your source messages.json, choose your target languages, and download a ZIP with the correct _locales/{lang}/messages.json structure. Placeholder tokens are preserved automatically. No account required.
Related guides
messages.json format explained (with placeholders)
Complete reference for the WebExtension messages.json format: message, description, and placeholders fields, $PLACEHOLDER$ syntax, and $1 positional substitutions.
Validate messages.json before shipping
Ready-to-run scripts for JSON syntax, missing keys, malformed placeholder syntax, and manifest key mismatches.
Chrome extension i18n: _locales structure and default_locale
How to set up the _locales folder, what default_locale does in manifest.json, and how Chrome picks the right messages.json at runtime.