Serve a second language

You need: nothing. i18n has no peer capabilities, no bindings, no migrations and no account.

Time: five minutes for the kit’s own strings. Longer for yours, because somebody has to write them.

1. Compose it

The i18n capability is what you are composing, supported locales is what ships with it, writing a locale is what to do when yours is not there, and checking your coverage is how you know you are done.

pithy add i18n
i18n({
  supportedLocales: ["en", "es"],
  defaultLocale: "en",
}),

That is the entire diff. No bindings, no migration, nothing durable.

The default must be one of the supported locales, and that is validated at config — so a mistake fails at boot with the field named rather than at the first request from an unexpected reader.

2. There is no step 2 for the kit’s strings

Spanish ships in the packages. Adding the tag is the whole server-side step for auth’s screens, email’s templates and every error code.

And nothing else changes. No route is edited, no capability is rewired, no call site moves — because c.var.t was already there. Core seeds every request with a translator over the baked English, and this replaces it with one that negotiates.

So composing this on a live 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.

3. Your own strings

Contributed through your app capability’s own messages, under your own domain:

const app = defineCapability({
  name: "app",
  messages: {
    en: { "app/nav.settings": "Settings" },
    es: { "app/nav.settings": "Ajustes" },
  },
  // …
});

A capability may only declare keys under its own name, and the composer refuses anything else — naming the key and the domain. That is the same rule as the table prefix and the error domain, for the same reason: the domain segment is what makes contributions incapable of colliding, so merge order stops being something anybody has to reason about.

Overriding a kit key is a different act and is always allowed. Declaring a new key under a kit domain is refused; overriding one the kit ships is one entry. Override one kit sentence covers it.

Key grammar

<domain>/<path>

The domain is a capability’s name. The path is that capability’s own name for the string — lowercase, underscores inside a word, dots between levels:

auth/sign_in.title
email/magic_link.expiry.one
app/nav.settings

A tag is at most 64 characters and a key at most 129.

Ship the least specific tag that is true

es, not es-ES.

The catalog locale falls back and the formatting locale does not — so a regional reader already gets regional dates, numbers and currency from a plain es catalog. Writing es-ES buys you nothing and costs a reader in Argentina their own formatting.

Locale selection is the model behind that.

Testing it

curl "http://localhost:8787/auth/sign-in?lang=es"

The URL parameter is first in the server’s chain, so it is the fastest check.

The more realistic test is a weighted Accept-Language header, and it is the one that exercises the part most implementations get wrong: the whole list is honored rather than its first entry, so a header asking for Portuguese, then Spanish, then English, from a project with no Portuguese, is a request for Spanish.

Composing it for one locale is legitimate

i18n({ supportedLocales: ["en"], defaultLocale: "en" }),

That translates nothing. What it buys is formatting — dates, numbers and currency rendered for where each reader actually is.

An American and a Briton both read English and write dates differently. This is the smallest useful thing the capability does, and it costs one config block.

In the browser

The scaffolded front end picks it up from the same config edit, including keeping the document’s language and direction attributes in step. Translate in the browser is the client half.

Check it worked

  • The URL parameter changes the language of a kit screen
  • A weighted header picks the right one from the middle of the list
  • Your own keys resolve, and a key you did not translate falls back to English
  • pithy doctor reports no coverage gaps
ESC