Project
Honest limits
What v0.1 does not do, with the reason and the measured cost for each.
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 memory grows with the catalog
Section titled “Build memory grows with the catalog”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.
Translations are a build input
Section titled “Translations are a build input”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.
One framework demo
Section titled “One framework demo”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.
Next.js is not a first-class SSR target
Section titled “Next.js is not a first-class SSR target”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.
Nuxt is not a first-class SSR target
Section titled “Nuxt is not a first-class SSR target”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.
Generated files live in your tree
Section titled “Generated files live in your tree”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,prebuildandpretypecheckkeep a fresh clone working once the firstdevorbuildhas run. - No
preparehook, on purpose: it runs onnpm ciand would rewrite the record before CI compares it. Continuous integration has the order. - No
--watch:loclizr build --no-failruns inpredev, and the example’s ownvite.config.tskeeps a small plugin for live catalog edits. - Delete that
.gitignoreto commit the tree;checkthen compares those files too.
The locale never comes from the URL
Section titled “The locale never comes from the URL”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.
No extraction and no codemod
Section titled “No extraction and no codemod”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.
The usage scan is a scan
Section titled “The usage scan is a scan”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.
Escape hatches
Section titled “Escape hatches”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 |
Intl is required
Section titled “Intl is required”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.x, no track record
Section titled “0.x, no track record”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.