c.var.t is always there
Core seeds it with the English each composed capability contributed under its own domain. The i18n capability replaces that translator with one that negotiated the reader’s locale and merged the catalogs behind it.
A project that never composes the capability is byte-identical to one from before any of this existed.
The surface
t.t(key, params?) // the KEY itself on a miss
t.maybe(key, params?) // null on a miss
t.plural(key, count, params?) // <key>.<category>, falling back to <key>.other
t.formatNumber(value, options?)
t.formatCurrency(value, currency, options?)
t.formatDate(Date | number, options?)
t.formatList(values, options?)
t.formatRelativeTime(value, unit, options?)
t.catalogLocale
t.formattingLocale
t.direction // "ltr" | "rtl"The same object reaches a React tree through useTranslator. Its two locale fields — and why they can differ — are catalogLocale and formattingLocale, which is what running two locales at once actually means.
t is total; maybe is not
t always returns a string — a screen has to render something, so a key no layer answers comes back as the key itself.
That makes t useless as a signal, which is why maybe exists.
Two locales, and collapsing them is a bug
catalogLocale falls back. An es-AR reader reads the es catalog, because es is what somebody wrote.
formattingLocale does not. That same reader gets es-AR handed to Intl, which supports it natively whether or not a translator ever did.
So Buenos Aires gets Spanish sentences and Argentine dates with nobody writing an es-AR catalog. That is the entire reason the two are separate fields.
c.var.locale carries the tags themselves
It is null unless the capability is composed — which is what nothing was negotiated looks like, as distinct from the default was chosen.
Read t to render a string. Read locale when you need the tags — for lang and dir on a document, or for the locale stamped onto an email job.
Where a person’s language lives
pithy_auth_users.locale is the one home, and it is nullable. Null means this reader has never chosen — not en.
That distinction is the column’s whole purpose: null falls through to Accept-Language, and en asserts a choice nobody made.
A reader writes it through the auth client’s updateUser({ locale }), which posts to Better Auth’s own update endpoint. It is the one kit field on the user table declared writable, guarded by the same schema that guards every read, and it accepts null so a reader can take a choice back.
Persisting a browser choice is a seam
The browser chain puts account above storage, so a reader who picks Spanish on their phone is not reading French on their laptop because that laptop’s storage holds an older choice.
For that ordering to describe anything, a signed-in reader’s choice has to be written through to their account. useNegotiatedLocale takes a persist callback and calls it whenever a reader picks a language:
persist: (next) => { void updateUser({ locale: next }); }It is a seam rather than something the capability does for you because the write goes through updateUser, which the i18n package never imports.
The result is discarded on purpose — persist returns void | Promise<void> while updateUser resolves to a result object, so returning it straight does not typecheck, and nothing is lost by dropping it because updateUser never throws: every refusal is a value it hands back. await it inside the callback if a dropped preference write is worth reporting.