Skip to content
loclizr
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

loclizr is a command line compiler. loclizr build reads JSON catalogs under locales/ and writes typed ESM message functions into your source tree.

  • No bundler plugin, no AST pass: nothing rewrites application code, so a framework’s next compiler release has no transform order to break.
  • One output runs in a Vite SPA, a server render, React Native and plain Node.
  • ICU MessageFormat, the catalog syntax for arguments and plurals (Catalogs), is parsed at build time; the browser ships no parser.
  • The locale is read at call time, so switching language needs no reload.
Output What it gives you
Message modules One module per top-level key segment. A function’s first parameter is exactly its ICU argument set, so a renamed placeholder, or a plural argument handed a string, fails typecheck at the call site instead of rendering undefined.
Diagnostics Fifty-nine rules with stable codes, on from the first build; an error exits 1. All but three re-level to off, warn or error in config.
Context record locales/loclizr.context.json, committed. Per message: canonical source text and its hash, argument names and types, plural or select shape, the file and function that use it, per-locale status, and your description if you wrote one.

Checking is part of build, not a step you wire up. It catches missing and blank translations, ICU syntax errors, arguments that differ between locales, plural categories a locale can never select, and keys differing only by a homoglyph or an invisible joiner. A missing translation renders the source text and is reported.

No timestamps or line numbers: the record moves when the contract moves, in the string edit’s pull request, for whoever translates next (a person, a model or a TMS).

A new app gets a hard gate from its first commit; an app with English literals in JSX retrofits by hand, below.

On i18next? Point catalogs at your files (Importing i18next catalogs). Format is decided per file, so i18next and ICU files mix, and nothing is written back: i18next keeps serving the app while you read the diagnostics.

No extractor: run this loop one screen at a time.

  1. checkout.title for the checkout heading. One top-level segment per screen is one module (messages/checkout.js), so a deleted screen takes its module with it.

  2. Put it in locales/en.json under that key.

  3. Replace the literal with m.checkout_title(), or m.checkout_items({ count }) for a plural.

  4. loclizr build, typecheck, next literal.

  5. When the last literal is gone, raise LZ5005, which ships off, under severity in loclizr.config.ts:

    severity: { 'unused-message': 'warn' }

    It lists keys with no call found, and fires only once the scan finds a generated import, so a wrong glob costs one warning, not one per key.

Not in v0.1 What that means
Per-locale delivery Every locale is inlined into each used message function; bundle size grows with locale count. Paraglide ships one locale per request.
Extraction, codemod Nothing reads or rewrites your components; catalogs are authored or imported.
A second framework React with Vite is the tested example; the message layer is framework free. Next.js is not first-class; next-intl is built for it. Servers: Server rendering.
Runtime catalogs or format() A translation fix is a commit and a deploy. A TMS that opens pull requests fits; a dashboard for fixing a production typo live does not.
--watch loclizr build --no-fail in predev, plus a few inline lines in the example’s own vite.config.ts that rebuild on catalog edits (How it works).
Stability 0.x: a minor release may break.

Next: Quickstart. Honest limits explains each row; Comparison sets loclizr beside its nearest tools.