Skip to content
loclizr

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 loclizr

This page is rendered by loclizr. Switch the language in the header and it changes without a reload.

One catalog line, one build, one typed call: 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.

A JSON catalog in, ICU MessageFormat by default. i18next catalogs are detected per file and converted in memory.

locales/en.json
{
"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}}"
}
}
Terminal window
npx loclizr build

Typed functions out. The locale is read at call time: setLocale('de') needs no reload.

src/Cart.tsx
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 number

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.

The entry for cart.greeting in the example app (array indentation removed):

locales/loclizr.context.json (one entry)
{
"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"
}
]
}

{name} became {nmae}.

locales/de.json
{
"cart": {
"greeting": "Hallo {nmae}, dein Warenkorb ist bereit",
"items": "{count, plural, =0 {Dein Warenkorb ist leer} one {# Artikel im Warenkorb} other {# Artikel im Warenkorb}}"
}
}

Run after a green build, so one file is rewritten.

loclizr build
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.json
commit locales/loclizr.context.json; `loclizr check` compares it
2 messages, 2 locales (source en), 2 errors, 0 warnings
fell back to source text: de 1

Header: 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.

Only the default arm is left and serves German; fell back to source text: de 1 counts it.

src/loclizr/messages/cart.js
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.

Put {name} back.

loclizr build
wrote src/loclizr (1 file) and locales/loclizr.context.json
commit locales/loclizr.context.json; `loclizr check` compares it
2 messages, 2 locales (source en), 0 errors, 0 warnings
src/loclizr/messages/cart.js
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.

  • 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, loclizr external, 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-fail in predev, 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.