Skip to content
loclizr

Each is a decision with a cost, not a backlog item.

Every locale is inlined into every message function

Section titled “Every locale is inlined into every message function”

cart_items carries a de, a de-AT and an en arm, as does every message you call. Bundle size grows with locale count.

Messages x locales Generated tree, minified Gzipped
26 x 3, the example app 6.8 kB 2.1 kB
200 x 1 31.5 kB 3.5 kB
200 x 3 79.4 kB 6.2 kB
200 x 10 228.6 kB 15.1 kB
1000 x 1 154.2 kB 13.6 kB
1000 x 3 395.3 kB 26.2 kB
1000 x 10 1146.5 kB 66.7 kB
2000 x 10 2312.4 kB 129.5 kB

Measured with vite build in library mode, importing the whole barrel with loclizr external, so nothing tree-shakes. Catalogs: one short sentence per message, a quarter each plain text, placeholder, plural and select. Runtime on top, built the same way: loclizr 1.9 kB gzipped; loclizr/react adds 0.3 kB and loclizr/server 0.8 kB.

These are ceilings: a production build drops every message the app never calls. Dev mode serves whole namespace modules, and the barrel imports every one of them, so in dev the payload is the whole catalog whatever the layout. Only deep imports (./loclizr/messages/cart.js) bound it.

Per-locale delivery needs to own your build; loclizr does not. At ten or more locales, Paraglide fits better where it can own the build.

build and check compile every message in every locale in one Node process, so the heap they need grows with messages times locales.

Messages x locales Catalog JSON Smallest heap Peak RSS, build Peak RSS, check
1000 x 10 0.8 MB 64 MB 151 to 154 MB 151 to 153 MB
1000 x 30 2.6 MB 96 MB 213 to 225 MB 211 to 221 MB
5000 x 10 4.3 MB 160 MB 353 to 357 MB 348 to 356 MB
5000 x 30 13.2 MB 352 MB 712 to 813 MB 698 to 768 MB
10000 x 10 8.6 MB 288 MB 557 to 701 MB 546 to 696 MB
10000 x 30 26.6 MB 704 MB 1131 to 1358 MB 1061 to 1320 MB

Catalogs are shaped like the ones in the size table above, in namespaces of 50 messages. Catalog JSON is the total size of the locale files in decimal megabytes; heap and RSS megabytes are 1024 x 1024 bytes, the unit --max-old-space-size takes. Smallest heap is the lowest --max-old-space-size, in 32 MB steps, at which both commands passed three runs; one step lower failed at least once. Peak RSS is the maximum resident set size /usr/bin/time -l reports, as the range over nine runs with Node 22.22 on arm64 macOS and the default heap, some of them with other work on the machine; check ran without the generated tree, as it does in CI.

Size by the heap column, not the RSS columns. With memory to spare V8 collects late, which is why the 10000 x 30 build peaks above 1.1 GB on a workstation yet passes in a 1 GB container once it is given a heap. Inside a container Node sets the default heap to about half the memory limit (524 MB under 1 GB, 1048 MB under 2 GB, measured in node:22-slim with Node 22.23 on arm64), so the 10000 x 30 catalog runs out of heap in a 1 GB container with no flags and passes in 1.5 GB. At 1 GB it passes with NODE_OPTIONS=--max-old-space-size=768; Continuous integration has the step and the error you see without it.

A translation fix is a commit and a deploy, which suits a TMS that opens pull requests. Copy that must change without a deploy wants a runtime catalog loader such as i18next.

Content unknown at build time is out of scope

Section titled “Content unknown at build time is out of scope”

No format() for text that arrives at runtime, from a CMS or a feature flag: it needs a runtime parser, which the design never ships. It is deferred past v0.1, not promised, and would be a separate entry so the invariant holds for everyone else.

React with Vite is the one tested example: locale negotiation, hydration agreement, language switcher. Messages are plain ESM that run anywhere JavaScript does; the server entry covers Web Fetch handlers and anything that can hand over two header strings. Under Jest, its default CommonJS transform needs a config change first. Elsewhere you wire the locale in yourself.

In React a message call subscribes to nothing: key the root on the locale, or pass { locale: useLocale() } per call, one pattern per app. See Language switching.

No test, no example, no promise, for either router. Middleware runs in a different runtime and cannot wrap a render; the App Router entry is a v0.2 question.

loclizr/react emits no 'use client' directive: put it on your own component file. next-intl is built for that stack.

No test, no example, no promise. Nuxt middleware runs before the renderer and cannot wrap it, so the request scope needs a Nitro plugin around the app handler, or { locale } on every call. See Nuxt and Nitro.

src/loclizr/ is real files, gitignored by default through a .gitignore loclizr writes inside it: the cost of having no bundler plugin.

  • A build must run before anything imports the tree; predev, prebuild and pretypecheck keep a fresh clone working once the first dev or build has run.
  • No prepare hook, on purpose: it runs on npm ci and would rewrite the record before CI compares it. Continuous integration has the order.
  • No --watch: loclizr build --no-fail runs in predev, and the example’s own vite.config.ts keeps a small plugin for live catalog edits.
  • Delete that .gitignore to commit the tree; check then compares those files too.

No /de/pricing. The locale comes from a cookie, then Accept-Language, and hydration agreement depends on exactly that order, so prefix routing is out of scope, not unfinished.

v0.1 never reads your components for strings or rewrites one. Catalogs are authored or imported from i18next, so a retrofit starts from a catalog. A one-time instrumentation command is a question for after v0.1, never part of the build.

It tokenizes rather than type-checks, so renamed re-exports and computed access on a plain namespace go unseen. Its rules default to off or warn and its output is projected out of the record gate, so by default it cannot fail a build. Render sites carry no line number, see Context record.

The record carries two of five context fields

Section titled “The record carries two of five context fields”

Render site and argument types ship. Element role (the scan cannot read a JSX tree), surrounding copy and glossary hits do not; glossary is the first candidate after v0.1. See Context record.

There is no per-key or per-locale silencing: a rule is re-levelled for every key and locale at once.

Hatch What it does
identifiers renames a generated function whose mangled key collides or reads badly
severity re-levels any rule but three to error, warn or off, catalog-wide
./loclizr/messages/cart.js deep-imports one namespace module where the bundler tree-shakes nothing, see Generated code
{ locale } per call overrides the store for one message, see Language switching
record: false no context record, no LZ5003, no LZ5007
meta: false no description sidecar is read
deleting src/loclizr/.gitignore commits the generated tree, which check then compares file by file
BuildResult.program the lowered program from loclizr/compiler, for scripts needing more than the CLI prints

Generated code calls Intl.PluralRules, Intl.NumberFormat and Intl.DateTimeFormat unconditionally. Every current browser, Node and React Native has them; Hermes on Android needs its intl build, a one-line project setting.

0.1.2 is on npm with no promise about how long it stays maintained. While 0.x, a minor may break an invariant, with a changelog entry. Maintainers, cadence and what happens if this stops: Continuity.