A single broken placeholder can crash a production build. A missing plural rule can turn a polished app into a grammatical embarrassment across dozens of locales. Yet most engineering teams treat localization files as an afterthought, flat key-value pairs tossed into a /locales folder with no validation, no context, and no safety net.
This guide is for engineers who want to stop firefighting localization bugs and start building robust string pipelines. It covers the file formats you'll actually encounter, JSON, YAML, PO, and XLIFF, alongside ICU MessageFormat for handling plurals, gender, and selection logic. You'll learn concrete strategies for escaping, testing, diffing, and preventing runtime errors before they ever reach your users.
Choosing the Right File Format: JSON, YAML, PO, and XLIFF
Format choice shapes your entire localization workflow. It determines what metadata you can attach, how translators interact with your strings, and whether your tooling can catch errors early. There is no universally "best" format, only the right fit for your stack, team size, and translation process.
JSON: Simplicity and Ecosystem Support
JSON is the default for most JavaScript-based projects. Libraries like react-intl, next-intl, and i18next consume it natively, and every CI system can parse it without extra dependencies.
The trade-off is that flat JSON offers zero metadata. There's no standard way to attach translator notes, character limits, or context descriptions without inventing your own conventions. Nested JSON structures help organize keys by feature or screen, but deep nesting creates merge conflicts and makes diffing harder.
Best suited for: single-platform web apps, small-to-medium string volumes, teams already using JavaScript tooling end to end.
YAML: Readability vs. Fragility
YAML's indentation-based syntax is more human-readable than JSON, which is why Rails (config/locales/*.yml) and some Python frameworks default to it. Multiline strings are cleaner, and comments are natively supported, useful for inline translator notes.
The downside is real: YAML is notoriously fragile. A misplaced indent silently restructures your data. The Norwegian locale code no is parsed as a boolean false unless quoted. Tabs versus spaces cause invisible breakage. If you choose YAML, enforce strict linting with tools like yamllint and always quote locale codes.
PO/POT (Gettext): Battle-Tested for Desktop and Backend
GNU Gettext's PO format has been the backbone of Linux and open-source localization for decades. It stores source strings as keys (the msgid), which means translators always see the original text. Translator comments, extracted comments, and plural forms are first-class citizens in the format.
PO works well with C, C++, Python, PHP, and any backend that links against libintl. It's less natural for mobile or modern frontend stacks. The format also doesn't support rich metadata like XLIFF's <note>, state tracking, or segmentation data.
XLIFF 1.2 vs. 2.1: The Interoperability Standard
XLIFF (XML Localization Interchange Format) is an OASIS standard designed specifically for exchanging localization data between tools, TMS platforms, and translation providers.
| Feature | XLIFF 1.2 | XLIFF 2.1 |
|---|---|---|
| Inline markup | <ph>, <bpt>/<ept> | Simplified <ph>, <pc>, <sc>/<ec> |
| Metadata | <note>, <context-group> | <notes>, extensible modules |
| Segmentation | External (SRX) | Built-in <segment> and <ignorable> |
| State tracking | state attribute on <target> | Richer state + subState |
| Tooling support | Broad (legacy TMS, CAT tools) | Growing (modern TMS, SDL, memoQ) |
| Modules | None | Translation candidates, glossary, metadata, validation, size restriction |
XLIFF 1.2 has wider legacy support. XLIFF 2.1 is a cleaner spec with modular extensibility, size restriction modules, for instance, let you embed character-limit constraints directly in the file.
When should you standardize on XLIFF? When your localization pipeline involves external translators, multiple TMS platforms, or more than two target formats. XLIFF acts as a canonical interchange layer: you convert from your source format (JSON, PO, etc.) into XLIFF for the translation round-trip, then convert back. This decouples your codebase from your translation tooling and preserves metadata that simpler formats cannot carry.
For teams managing complex, multi-format pipelines, Ollang handles XLIFF-based workflows across text, software, and document localization. You can book a demo with Ollang to see how XLIFF interchange fits into an enterprise pipeline.
ICU MessageFormat: Plurals, Gender, and Select
Hardcoding plural logic with ternary operators (count === 1 ? "item" : "items") breaks the moment you add a language with more than two plural forms. Polish has four. Arabic has six. ICU MessageFormat, maintained by the Unicode Consortium, solves this with a declarative syntax that translators can work with directly.
Plural Rules with Concrete Examples
An ICU plural string for English looks like this:
{count, plural,
=0 {No messages}
one {# message}
other {# messages}
}
The # symbol is replaced with the numeric value at runtime. The categories (zero, one, two, few, many, other) are defined by CLDR plural rules, not by the developer. A translator working on the Arabic locale adds the zero, two, few, and many categories that Arabic requires, without any code change.
Gender and Select
The select keyword handles gender and arbitrary enums:
{gender, select,
female {She completed her profile.}
male {He completed her profile.}
other {They completed their profile.}
}
You can nest select inside plural or vice versa. Keep nesting to two levels maximum; beyond that, split the string into separate keys for maintainability.
How Do We Lint ICU Strings Automatically?
Use dedicated parsers that validate ICU syntax at build time:
- Ollang: integrates ICU validation into multi-format pipelines so checks run consistently across platforms.
- @formatjs/cli: The formatjs compile command parses ICU strings and throws on syntax errors. Integrates directly into React/Next.js projects.
- messageformat (npm): Parses and compiles ICU strings for JavaScript, reporting mismatched braces, unknown plural categories, and invalid selectors.
- i18next-icu: If you're on i18next, this plugin validates ICU at load time, but catching errors at build time is preferable.
- Custom CI checks: Run a script that loads every message file, parses each value through an ICU parser, and fails the build on any error. This takes fewer than 20 lines of code in most languages.
Add these checks to your pre-commit hooks (via Husky or pre-commit) so broken ICU strings never reach the main branch.
Escaping, Variables, and Placeholder Protection
Placeholders are the most common source of localization-related production incidents. A translator accidentally deletes {username}, and the app renders a raw variable name, or worse, crashes.
Defining Placeholder Conventions
Establish a single convention and enforce it across all files:
- Named placeholders: {username}, {orderTotal}, self-documenting, harder to confuse.
- Positional placeholders: %1$s, %2$d, common in Android (strings.xml) and C-style systems. Positional indexing lets translators reorder words without breaking substitution.
- HTML-like tags: <bold>{name}</bold>, used by react-intl and i18next for inline formatting. Protect these as non-translatable inline elements.
Protecting Placeholders in Translation
In XLIFF, wrap placeholders in <ph> (placeholder) elements so CAT tools render them as locked, movable tokens. In JSON-based workflows, use linter rules that compare source and target placeholder sets. The @formatjs/cli toolchain can extract placeholders and verify they appear in every locale file.
For escaping, ICU MessageFormat uses single quotes as escape characters: '{'} produces a literal{. This trips up translators unfamiliar with the convention. Document it explicitly in your style guide, and consider adding a linter that warns when literal braces appear without escaping. Remember that a literal single quote is escaped by doubling it:''`.
