Open source, MIT licensed
Checking your catalog shouldn't be a tool you remember to add.
loclizr build turns your JSON translation catalogs into typed message functions, checks the catalogs on every build, and writes a context record for whoever translates next.
npm i loclizrThis page is rendered by loclizr. Switch the language in the header and it changes without a reload.
m.demo_items renders in all 13 locales, and a wrong argument type fails tsc.Typed message functions
Plain ESM with declarations, one function per message. A renamed placeholder breaks the call site at typecheck, not in production.
Checks that are already on
Missing translations, ICU syntax errors and arguments that differ between locales fail the build.
A context record per message
Argument types, plural shape and where each message is used, committed in the same pull request as the string change.
Sixty seconds
Section titled “Sixty seconds”A JSON catalog in, ICU MessageFormat by default. i18next catalogs are detected per file and converted in memory.
{ "cart": { "greeting": "Hi {name}, your cart is ready", "items": "{count, plural, =0 {Your cart is empty} one {# item in your cart} other {# items in your cart}}" }}npx loclizr buildTyped functions out. The locale is read at call time: setLocale('de') needs no reload.
import * as m from './loclizr/messages'
m.cart_greeting({ name: 'Ada' }) // "Hi Ada, your cart is ready"m.cart_items({ count: 3 }) // "3 items in your cart"m.cart_items({ count: '3' }) // type error: count is a numberTry it
Section titled “Try it”Hello, Ada!
3 items in your cart
Total: €73.50
On its way
Updated Oct 3, 2026
m.demo_items({ count: 3 }, { locale: 'en' })This site compiles its own catalogs with loclizr. The Playground shows one count in all 13 languages at once.
One pass, three outputs
Section titled “One pass, three outputs”The entry for cart.greeting in the example app (array indentation removed):
{ "key": "cart.greeting", "id": "cart_greeting", "module": "messages/cart.js", "kind": "text", "source": "Hi {name}, your cart is ready", "sourceHash": "54db56b1aeaf77ae", "description": null, "args": [ { "name": "name", "type": "text", "options": null, "note": null } ], "variants": [], "markup": [], "translations": [ { "locale": "de", "status": "translated", "from": null, "reason": null }, { "locale": "de-AT", "status": "translated", "from": null, "reason": null }, { "locale": "en", "status": "translated", "from": null, "reason": null } ], "usage": [ { "file": "src/App.tsx", "scope": "App" } ]}What a failing build looks like
Section titled “What a failing build looks like”Break a German translation
Section titled “Break a German translation”{name} became {nmae}.
{ "cart": { "greeting": "Hallo {nmae}, dein Warenkorb ist bereit", "items": "{count, plural, =0 {Dein Warenkorb ist leer} one {# Artikel im Warenkorb} other {# Artikel im Warenkorb}}" }}Build fails with exit 1
Section titled “Build fails with exit 1”Run after a green build, so one file is rewritten.
error LZ3004 arg-missing locales/de.json:3:18 de cart.greeting
The en text uses {name} and the de translation does not.
fix add {name} to "cart.greeting" in locales/de.json
error LZ3005 arg-extra locales/de.json:3:18 de cart.greeting
The de translation uses {nmae}, which the en text does not have, so this locale renders en text instead.
fix check the spelling of {nmae} in locales/de.json, or add it to the en text
wrote src/loclizr (1 file) and locales/loclizr.context.jsoncommit locales/loclizr.context.json; `loclizr check` compares it2 messages, 2 locales (source en), 2 errors, 0 warningsfell back to source text: de 1Header: severity, code, rule, file:line:column, locale, key. One typo, two errors: the compiler
does not guess which was meant.
| Error | Caught | Effect |
|---|---|---|
LZ3004 arg-missing |
German lost {name}. |
Exit 1. Alone, it keeps the German arm, rendered without the name. |
LZ3005 arg-extra |
German uses {nmae}, which args never carries. |
Dropped the German arm: args.nmae would print undefined. |
What ships meanwhile
Section titled “What ships meanwhile”Only the default arm is left and serves German; fell back to source text: de 1 counts it.
export function cart_greeting(args, opts) { switch ($l(opts)) { default: return `Hi ${args.name}, your cart is ready` }}m.cart_greeting({ name: 'Ada' }, { locale: 'de' }) // "Hi Ada, your cart is ready"m.cart_items({ count: 3 }, { locale: 'de' }) // "3 Artikel im Warenkorb"German visitors get English for cart.greeting; the untouched cart.items stays German.
Fix and rebuild
Section titled “Fix and rebuild”Put {name} back.
wrote src/loclizr (1 file) and locales/loclizr.context.jsoncommit locales/loclizr.context.json; `loclizr check` compares it2 messages, 2 locales (source en), 0 errors, 0 warnings case 'de': return `Hallo ${args.name}, dein Warenkorb ist bereit`The tree is written even on error, so the app keeps typechecking and real errors are not buried under “cannot find module”. The exit code gates:
| Command | Exit on error | Use |
|---|---|---|
loclizr build |
1 | prebuild, local gate |
loclizr build --no-fail |
0, same diagnostics | dev server |
loclizr check |
1, writes nothing | CI. The same two errors, plus LZ5002 output-stale for cart.js if the green tree is on disk. |
A target value that fails to lower, or uses an argument the source lacks, takes the fallback chain. A source value that fails to parse gets no function: see fatal scope.
Where to go next
Section titled “Where to go next”What it deliberately does not do
Section titled “What it deliberately does not do”- No per-locale delivery. Every locale is inlined into each used message function, so bundle
size grows with locale count. Example app, 26 messages in 3 locales,
vite build: tree plus runtime 7.0 kB minified (2.6 kB gzipped); tree alone,loclizrexternal, 4.7 kB (1.6 kB); runtime alone 2.6 kB (1.2 kB). - No runtime
format(). ICU is parsed at build time; the browser ships no parser, and content unknown at build time has no formatter. - No extraction. Nothing finds or rewrites hardcoded strings. Catalogs are authored or imported.
- Servers: Web Fetch handlers. Express and Fastify via
localeFromHeaders. Next.js is not first-class in v0.1. - No URL-prefix locale routing. Locale comes from a cookie or
Accept-Language. - No
--watch.loclizr build --no-failinpredev, or the example’s inline Vite plugin. - Translations are a build input. A copy fix is a commit and a deploy. To change copy in production without a release, use i18next with a backend.
0.x. A minor release may break.
Day zero: no users yet, no track record, no maintenance promise. MIT, with the maintenance situation written down.