i18n overview

Language for a Pithy app — negotiated per request, rendered through one seam. One piece of middleware. No tables, no migrations, no bindings, no error codes of its own.

It answers one question — what language is this reader in? — and hands every capability, every screen and every email a translator that already knows the answer.

The seam is in core, and this fills it

c.var.t is always there.

Core seeds every request with a translator over the English each composed capability contributed, so a capability writes c.var.t.t("auth/invalid_token") with no null check and no config. This capability replaces that translator with one that negotiated the reader’s locale and merged the catalogs behind it.

A project that never composes it is byte-identical to one from before any of this existed. Same strings, same bytes, no negotiation, no merge. That is the property the whole design is arranged around, and it is why this is optional rather than a dependency of core.

c.var.t.t("auth/sign_in.title");
c.var.t.plural("email/magic_link.expiry", 15);
c.var.t.formatCurrency(1250, "EUR");
c.var.locale?.direction;

Two locales, and only one of them falls back

The reader’s tag does two different jobs, and collapsing them is the bug where an Argentine reads Spanish and sees American number formatting.

What it isFalls back?
Catalog localeThe locale whose catalog answers a lookup — the words somebody actually wroteYes. An es-AR reader gets es, because es is what is written
Formatting localeThe locale handed to IntlNo. An es-AR reader gets es-AR, which Intl supports natively whether or not a translator ever did

So a reader in Buenos Aires gets Spanish sentences and Argentine dates, numbers and currency, from one translator, with nobody writing an es-AR catalog.

Two negotiation chains, because the two sides are different

localStorage is absent from a Worker, and navigator.language inside the Workers runtime is a constant.

Server: the URL parameter, then the signed-in reader’s account, then a cookie, then Accept-Language, then the project default.

Browser: the query, then the account, then storage, then what the server said, then the default.

Both orders are configuration, and the default is the last resort whether or not it is listed.

Accept-Language is honored as the whole weighted list, not its first entry. A header asking for Portuguese, then Spanish, then English, from a project with no Portuguese, is a request for Spanish — and reading only the head answers English. A malformed header falls through to the default rather than throwing, because a 500 on the request path is not a negotiation strategy.

The account outranks the device. A reader who picks Spanish on their phone is not reading French on their laptop because that laptop’s storage holds an older choice.

An override is a merge, not a fork

A lookup walks layers per key: your catalog for the resolved locale, your catalog for the default, the kit’s translation, then every composed capability’s English.

Per key, never per catalog. Overriding one sentence is one entry, and every key you did not mention keeps flowing from the package:

i18n({
  supportedLocales: ["en", "es"],
  messages: {
    es: { "auth/sign_in.title": "Entrar" },
  },
});

The kit’s translations are never copied into your repository. If they lived in your tree, a typo fix or a new locale could never reach you, and every adopter would become a fork on the day they scaffolded.

What ships, and what does not

Spanish ships. English does not, and that is not an omission: English is the source. An error carries its English message on the wire, a capability contributes its English through the composition contract, and a copied screen carries the English it was scaffolded with. A second copy would be a second place for one sentence to drift.

What stays English, permanently

Error payloads. A message on the wire is English forever, and it carries structured params beside it — so a translating client renders its own string from the code and falls back to the English when it has none. The code is the translation key, so there is no second identifier to keep in sync.

Operator diagnostics. Log records, CLI output, audit rows. Those are read by you and by whoever is on call, and a stack trace in a language the responder does not read is worse than useless.

What it deliberately does not do

It does not translate anything. It resolves a locale and looks up a string. Writing the Spanish is writing the Spanish.

It brings no dependency, and cannot. Adapters exist for the common i18n libraries, and none of them is a dependency of this package — you bring yours, or you use the built-in translator.

It has no tables, no migrations, no bindings and no error domain, and its reference page says so plainly. There is nothing durable and nothing to provision.

It does not localize your database content. A product name your users typed is your data, and where it goes is your schema.

When you would reach for it

When you serve readers in more than one language. Or — more often, and the better reason — when you serve readers in one language and want formatting that is right for where they are.

Composing it for en alone is a legitimate thing to do: you get correct dates, numbers and currency per reader with no catalog work at all.

What it needs

Nothing. No peers, no secrets, no bindings, no account.

ESC