pithy add i18nNo prerequisites, no bindings, no migrations, no account, no secrets. The smallest add in the kit alongside turnstile.
What lands in your repo
apps/<worker>/pithy.config.ts gains the registration, and that is the entire diff:
i18n({
supportedLocales: ["en", "es"],
defaultLocale: "en",
}),wrangler.jsonc gains nothing. No migration runs. There is nothing durable here at all.
What changes at runtime
Everything and nothing.
c.var.t was already there before you composed this — core seeds every request with a translator over the English each composed capability contributed. So no route changes, no capability needs rewiring, and no call site is edited.
What changes is what that translator is: one that negotiates the reader’s locale per request and merges catalogs behind it, instead of one that always answers English.
Which means composing this on an existing project is a config edit and a redeploy. Every screen, every error and every email starts negotiating, and nothing had to be prepared for it.
The two config options
| Option | Default | What it decides |
|---|---|---|
supportedLocales | — | Every locale this project serves |
defaultLocale | en | Served when nothing else answers. Must be one of supportedLocales |
Both are validated at config, so a default outside the supported set fails at boot with the field named rather than at the first request from an unexpected reader.
Composing it for one locale is legitimate
i18n({ supportedLocales: ["en"], defaultLocale: "en" }),That writes no Spanish and translates nothing. What it buys you is formatting — dates, numbers and currency rendered for where each reader actually is, because the formatting locale does not fall back the way the catalog locale does.
An American and a Briton both read English and write dates differently. This is the smallest useful thing this capability does, and it costs one config block.
Overriding one sentence
Lookups walk layers per key, so an override is one entry:
i18n({
supportedLocales: ["en", "es"],
messages: {
es: { "auth/sign_in.title": "Entrar" },
},
}),Every key you did not mention keeps flowing from the package. A typo fix or a new locale in the kit reaches you as a package upgrade rather than as a merge into files you own.
Passing a whole locale object is the fork, and that is why no eject command is offered for this and none is needed. You can always take everything; you just usually should not.
Serving a second language
The kit ships Spanish. Adding it to supportedLocales is the whole server-side step for the kit’s own strings — auth’s screens, email’s templates, every error code.
Your own strings are yours to write, contributed through your app capability’s own messages under its own domain, exactly as a kit package contributes its English.
English is not a catalog, and asking for one is asking for the source. Errors carry their English on the wire permanently, with structured parameters beside it, so a translating client renders its own string from the code and falls back to the English when it has none.
In the browser
The React stub pithy ui add scaffolds reads the composed config at runtime, so a front end picks up a second language from the same config edit — including keeping the document’s language and text-direction attributes in step.
The browser’s negotiation chain is its own, because half the server’s links do not exist there. Both are configuration.
Checking your coverage
It is a pithy doctor finding rather than a command of its own, and it fails the exit code — so a locale with gaps is caught in CI rather than by a reader.
That placement is deliberate: coverage is a health question about a project, and health questions live in one report.
Check it worked
curl "http://localhost:8787/auth/sign-in?lang=es"The URL parameter is first in the server’s chain, so it is the fastest way to confirm negotiation is running. An Accept-Language header with a weighted list is the more realistic test, and the one that exercises the part most implementations get wrong.