Internationalization (i18n)
Byline's i18n is a sophisticated system that grew out of several real project "edge-cases" — repeated requests to build sites where content could be translated independently of the interface it was presented in. Supporting that cleanly is, in fact, one of the reasons Byline CMS exists.
The result is an end-to-end implementation that separates interface translations from content translations. A site can present content in one or more content translations that lie entirely outside the site's own interface translations. And it does so correctly: additionally-translated pages get the right hreflang, canonical, and sitemap entries — everything needed to make a translated document discoverable — while at the same time preventing a content translation from becoming a "switched", unknown interface locale that flows through links and "sticks" to the rest of the site as the visitor navigates on.
Read this first — there are two separate but coordinated i18n systems. Throughout this document (and the codebase) we discuss two distinct systems:
On top of those two interface axes sits a third, independent axis: content locales — the languages a document can be published in. Content locales are defined inside Byline and are separate from, and independent of, Byline's own admin-interface translations.
So all three can differ. A realistic configuration:
Axis | Owner | Example |
Host interface translations | the host frontend |
|
Byline admin interface translations |
|
|
Byline content translations | Byline storage / read pipeline |
|
The three sets overlap only by coincidence. An editor working in a Spanish-language admin chrome routinely edits English, French, and German content; a visitor reading the English public site can be handed one Japanese article without the site flipping into Japanese around them.
Reference-app note. The example app in this repo is configured more modestly than the table above — host interfaceen/fr, admin interfaceen/fr, contenten/fr/es/de— because that is enough to exercise every mechanism. The point of the table is what the system permits, not what the demo ships.
This document is organised the way the systems stack:
- The host i18n system — the public-site half.
- Byline's i18n system — split into admin-interface translation and content locales (resolution, fallback, and advertising).
- Administering content locales — the one-time administrative task of switching a system's default content locale.
- Remaining work — notable TODOs.
The host i18n system
This repo ships a worked, copy-and-adapt example of a host frontend that coordinates with Byline's content locales. It is a reference, not a turnkey module — SEO conventions, URL strategy, and routing differ per host and per framework, so Byline deliberately does not bake one opinion into the CMS.
What the host owns
Byline core stops at facts. Per read, it tells you which content locale a document resolved to, and which locales it is available and advertised in. Turning those facts into routes, <link> tags, a sitemap, or an "Also available in…" affordance is the host application's job. The same boundary already governs path (core stores the slug; the host composes the URL) and the admin interface i18n described later.
The host owns:
- URL shape & locale routing — whether a locale is a path prefix (
/de/news/foo), a subdomain, or a query param; which locales are routable (resolvable) vs merely advertised (promoted); and the non-sticky rule (below). - <link rel="canonical"> + hreflang alternates (including
x-default), built from the advertised set. - sitemap.xml alternates — the same advertised set, kept in sync with the
hreflangtags (ideally from one shared resolver). - The per-page "Also available in…" menu — content-locale links gated on availability, distinct from the global interface-language switcher.
- <meta> / Open Graph / Twitter tags.
Routable vs advertised, and the non-sticky rule
The key design idea on the host side is that the set of locales a URL can resolve is wider than the set it promotes:
routableLocales = interfaceLocales ∪ contentLocalesThe {-$lng} route matcher resolves any routable locale, so a content-only deep link such as /ja/news/foo works even though the frontend chrome has no Japanese bundle (the chrome falls back to the default interface locale; the content still renders in Japanese).
But a content-only locale must not become sticky. If a visitor on the English site follows one Japanese article, the /ja prefix must not pin Japanese into their session and follow them onto every subsequent link. The host enforces this in three coordinated places:
- Server middleware (
src/middleware/locale-redirect.ts) — a routable locale segment passes straight through without interface negotiation and without writing thelngcookie. Only interface-locale negotiation (cookie +Accept-Language) writes the cookie. - Navigation hook (
src/i18n/hooks/use-locale-navigation.ts) — persists thelngcookie only when switching to an interface locale. A content-locale navigation target (the "read this in…" affordance) never writes the cookie, so the prefix stays opt-in per document. - Language switcher (
src/i18n/hooks/use-language-switcher.ts) — lists interface locales only, and strips any existing routable prefix (interface or content) before applying the new one, so switching off/jacan never produce/es/ja/....
The net effect is exactly the property called out in the introduction: a content translation is discoverable and linkable, but it never silently switches and sticks as an interface locale.
The single reach into Byline
The host frontend needs to know Byline's content-locale set, and it gets it from one place: apps/webapp/byline/locales.ts. That file is a dependency-free leaf module — plain data, zero @byline/* imports — precisely so the public frontend can import the locale arrays without dragging the admin translation graph (@byline/i18n/admin and its Lexical-adjacent module tree) into the public client bundle.
// apps/webapp/byline/locales.tsexport const interfaceLocales = [ { code: 'en', label: 'English' }, { code: 'fr', label: 'Français' },]
export const contentLocales = [ { code: 'en', label: 'English' }, { code: 'fr', label: 'Français' }, { code: 'es', label: 'Español' }, { code: 'de', label: 'Deutsch' },] as constbyline/i18n.ts consumes these to assemble the defineServerConfig / defineClientConfig payload; the public frontend's src/i18n/i18n-config.ts imports contentLocales directly to build its routableLocales. The host authors the display labels here once (Français, not CLDR's lowercase français), and the server-side consumers (sitemap / getMeta) read the same set via getServerConfig().i18n.content.localeDefinitions — one source of truth, no parallel map.
Why not call Byline's config getters from the public client bundle?getServerConfig()/getPublicConfig()pull in the admin graph and are server-only. The leaflocales.tsis the deliberate, bundle-safe seam — import it, notbyline/i18n.ts, from public client code.
Reference implementation files
A worked TanStack-Start host, all under apps/webapp/:
Concern | Location |
Routable-locale config, |
|
Non-sticky server middleware |
|
Locale-aware navigation (cookie only on interface switch) |
|
Interface language switcher (strips routable prefixes) |
|
Per-page "Also available in…" affordance |
|
Advertised-set resolver ( |
|
Canonical + |
|
Frontend translation bundles + provider |
|
advertisedLocalesFor(doc) computes the public advertised set as the intersection availableLocales ∩ _availableVersionLocales (see Advertising content locales); resolveAlternates(...) turns it into { canonical, alternates, xDefaultPath }, the single resolver that hreflang meta — and a future sitemap.xml — both derive from, so the two can never drift. A sitemap.xml route is not shipped in the example, but it would consume the same resolver.
Byline's i18n system
Byline's own i18n splits along the two axes introduced at the top:
- Admin interface translations — what language the Byline admin UI renders in. Owned by the
@byline/i18npackage. - Content locales — what language a document publishes in. Owned by the storage / read pipeline. Covers resolution, fallback, and the editorial advertising control.
These two are deliberately independent. The admin shell language switcher never forces document content into the same language; the public site's content locale never forces the admin chrome into the visitor's locale.
Admin interface translations
The Byline admin shell renders end-to-end in English and French today, with hooks for plugins, custom fields, and extensions to register their own translations. This is the @byline/i18n package.
Package layout
- @byline/i18n (root) — React-free: the
TranslationBundletypes,mergeTranslations, the ICU formatter, and locale resolution. Safe in server contexts. Depends on@byline/coreonly — a leaf package. - @byline/i18n/react — the single React barrel:
I18nProvider,useTranslation,LanguageMenu. - @byline/i18n/admin — the built-in
byline-adminnamespace bundle (EN/FR) plus theadminTranslations({ locales })factory.
The host integration lives in @byline/host-tanstack-start: per-request locale resolution (src/i18n/resolve-locale.ts), cookie helpers (src/i18n/locale-cookie.ts), a server-side translator (src/i18n/server-translator.ts), and the locale-persistence server fns (src/server-fns/i18n/*).
Quick reference
Each entry is the minimal shape for one task. The Edit line tells you which file you actually change.
1. Enable the bundled English admin
Default registration. Every admin shell string ships in English; no per-locale work needed yet.
Edit: apps/webapp/byline/admin.config.ts
import { defineClientConfig } from '@byline/core'import { adminTranslations } from '@byline/i18n/admin'
defineClientConfig({ i18n: { interface: { defaultLocale: 'en', locales: ['en'] }, translations: adminTranslations({ locales: ['en'] }), }, // … the rest of your client config})adminTranslations({ locales }) reads bundled JSON files in @byline/i18n/src/admin/ and returns the byline-admin namespace for each requested code. Skipping it is a hard error at startup — the validator refuses to mount the admin without at least one registered locale.
2. Add a second locale
Every bundled translation lives in-package at packages/i18n/src/admin/<code>.json. To enable a locale, list its code in i18n.interface.locales and pass the same codes to adminTranslations({ locales }). Today's bundle ships en and fr; adding more is a one-file PR (drop a new JSON, add it to the bundle map).
defineClientConfig({ i18n: { interface: { defaultLocale: 'en', locales: ['en', 'fr'] }, translations: adminTranslations({ locales: ['en', 'fr'] }), },})The interface block stays separate from the content-locale list — admin UI in French does not force document content into French. adminTranslations({ locales: ['xx'] }) throws at config time when the requested code is not bundled.
3. Translate a string in your own admin code
Inside any admin shell component or admin server fn output. Same hook everywhere.
import { useTranslation } from '@byline/i18n/react'
export function PublishButton() { const { t } = useTranslation('byline-admin') return <button>{t('actions.publish')}</button>}4. Contribute translations from your own code
A custom component, a custom field, a plugin, a richtext extension — anything outside @byline/admin follows the same shape: per-locale JSON files inside your own package (or host), plus a factory matching adminTranslations's { locales } signature. The host wires every source explicitly through mergeTranslations(...). See the worked example below.
5. ICU formatting (plurals, dates, numbers)
ICU MessageFormat works inline — the same syntax the host i18n already uses.
// translations'inbox.unread': '{count, plural, one {# unread message} other {# unread messages}}','doc.publishedOn': 'Published on {date, date, medium}',const { t } = useTranslation('byline-admin')t('inbox.unread', { count: 3 }) // "3 unread messages"t('doc.publishedOn', { date: new Date() }) // "Published on May 28, 2026"6. Server-side translation (loaders, server fns)
Same translation surface from a server context. Returns a pre-bound t(key, values) for the request's resolved locale.
import { createServerFn } from '@tanstack/react-start'import { resolveServerTranslator } from '@byline/host-tanstack-start/i18n'
export const sendInvite = createServerFn({ method: 'POST' }) .handler(async () => { const { t } = await resolveServerTranslator('byline-admin') return { subject: t('email.invite.subject') } })The translator resolves the locale once per request from the same cascade the client uses (preferred_locale → cookie → Accept-Language → default).
7. The language-switcher menu
The built-in <LanguageMenu> mounts in the admin chrome. It reads the registered i18n.interface.locales, the current request locale, and calls the server fn that persists the new preference. No host-side wiring required.
import { LanguageMenu } from '@byline/i18n/react'
<LanguageMenu className="my-2" /> // to render it somewhere else, e.g. an Account card8. Set the locale on an admin user account
The Account page exposes a "Default language" field backed by byline_admin_users.preferred_locale. Toggling it updates the user record (cross-device) and writes the cookie (immediate). When preferred_locale is null, detection falls through to cookie / Accept-Language / default. The same field is surfaced on the admin-users list so a super-admin can pre-set a colleague's locale.
Architecture
The contract surface
Six things compose the present surface:
- LocaleCode / LocaleDefinition — the existing types in
@byline/core(i18n.interface.locales,i18n.interface.defaultLocale). - The translation registry — a frozen map of
{ [locale]: { [namespace]: { [key]: string } } }produced bydefineClientConfig({ i18n: { translations } }). Built once at startup, read-only thereafter. - The t(key, values?) formatter —
intl-messageformat-backed, identical signature on client and server. - The React provider + useTranslation(namespace) hook — the only client-side consumer surface. Throws if mounted outside the provider.
- The locale resolver —
resolveInterfaceLocale({ preferred, cookie, acceptLanguage }). A pure function the host calls once per request and threads into the provider. - The locale-persistence server fn —
setInterfaceLocaleFn({ lng }). Updates the admin user record (if authenticated) AND thebyline_admin_lngcookie. Cookie-only when no actor is present (e.g. the login page).
TranslationBundle is intentionally just JSON — no functions, no React, no per-key metadata — which keeps the file format diff-friendly, importable by every translation tool that round-trips JSON, and easy for a third-party plugin to ship inside its own package:
export type TranslationBundle = { readonly [locale: string]: { readonly [namespace: string]: { readonly [key: string]: string } }}The translation hook
function useTranslation<NS extends Namespace>(namespace: NS): { t: (key: string, values?: Record<string, unknown>) => string locale: string}The hook reads the registry off context, looks up namespace, and returns a t bound to it. The returned t always returns a string — never undefined, never a React element. Components that need rich-text interpolation (e.g. an <a> inside a translated paragraph) use a separate <Trans> component wrapping the same formatter. The hook throws if mounted outside <I18nProvider>; the provider is mounted automatically by the host adapter's admin shell root.
Server-side translation
@byline/host-tanstack-start/i18n exports resolveServerTranslator(namespace), which reads the request's resolved locale (via getAdminRequestContext()), looks up the namespace from the same registry the client uses, and returns a { t, locale } identical in shape to the client hook's return. Loaders, createServerFn handlers, and email templates all use the same call.
Translation registration
Three ways to register, all converging on the same TranslationBundle:
- The built-in admin bundle.
adminTranslations({ locales })reads bundled JSON frompackages/i18n/src/admin/and returns thebyline-adminnamespace. The available codes are exported asbundledLocales; unknown codes throw. - A plugin's exported factory. Plugins ship per-locale JSON inside their own package plus a factory taking
{ locales }. The host merges viamergeTranslations(adminFactory({...}), pluginFactory({...})). - Ad-hoc inline, for a small custom field or one-off override:defineClientConfig({i18n: {translations: mergeTranslations(adminBundle, {en: { 'my-app': { 'banner.welcome': 'Welcome back' } },fr: { 'my-app': { 'banner.welcome': 'Bon retour' } },}),},})
Why explicit merge rather than side-effect registration. Side-effect registration creates load-order dependencies — the plugin must be imported before any UI renders and its import side-effect must actually run (which dead-code elimination can defeat). The explicit-merge model mirrors what RichTextField registration already does: the host's admin.config.ts is the one file that knows about every wired-in subsystem.
Locale configuration
Default locale + permitted set live on i18n.interface. One optional companion slot carries display names for the language switcher:
i18n: { interface: { defaultLocale: 'en', // fallback when detection yields nothing useful locales: ['en', 'fr'], // permitted set; values outside are rejected localeDefinitions: [ // optional — display names for the switcher { code: 'en', nativeName: 'English' }, { code: 'fr', nativeName: 'Français' }, ], }, content: { defaultLocale: 'en', // default content locale for new documents locales: ['en', 'fr', 'es', 'de'], // languages a document can be published in localeDefinitions: [ // optional — display names for content locales { code: 'en', nativeName: 'English' }, { code: 'fr', nativeName: 'Français' }, { code: 'es', nativeName: 'Español' }, { code: 'de', nativeName: 'Deutsch' }, ], }, translations: { /* … */ }, // required when interface.locales is non-empty}localeDefinitions is the host's chance to override what Intl.DisplayNames produces — most commonly to capitalize romance-language names (Français rather than CLDR's français). Per-code resolution is: explicit localeDefinitions entry → Intl.DisplayNames(code).of(code) → the raw code. Partial coverage is fine.
The content dimension accepts the same optional localeDefinitions slot. Byline itself never renders it — the content-locale set has no admin switcher — but it travels through getServerConfig().i18n.content.localeDefinitions so a host frontend can label its own content-language affordances (hreflang clusters, "read this in…" links, sitemap alternates) with author-controlled names. The same resolution order applies, via the exported buildLocaleDefinitions(codes, localeDefinitions) helper from @byline/host-tanstack-start/i18n.
initBylineCore() validates at boot: every locale in interface.locales has at least one namespace in translations (missing → fail fast with a pointer to adminTranslations({ locales })); defaultLocale is in interface.locales; and key-set drift between locales surfaces as a warning — partial translations are fine, but contributors see the gap.
Lookup and fallback
For t('button.publish', { count: 3 }):
- Active locale — try
bundle[activeLocale][namespace]['button.publish']; if present, format with ICU and return. - Default locale — try
bundle[defaultLocale][...]; if present, format and return; in dev,console.warnonce per(locale, namespace, key)about the miss. - Key fallback — return the raw key. Loud-by-default: the user sees
button.publishon screen, which is uglier than the English fallback but makes the gap impossible to miss in development.
Locale detection cascade
Per request, resolved once, identical on client and server (so no SSR/hydration flicker):
- byline_admin_users.preferred_locale — the authenticated user's explicit choice. Wins when set.
- byline_admin_lng cookie — set on every language switch. A different cookie name from any host-side
lngcookie, to avoid cross-talk. - Accept-Language negotiation — via
@formatjs/intl-localematcher, matching againsti18n.interface.locales. - i18n.interface.defaultLocale — last resort.
Per-user locale preference
byline_admin_users.preferred_locale (varchar 16, nullable; null = "use detection cascade") is surfaced in two places: the Account preferences page (a Select of interface locales plus a "Use browser default" option that sets the column back to null), and the admin users list (so a super-admin can set a colleague's default before they first log in). The server fn that updates the column also writes the cookie, so the change is visible without a sign-out / sign-in cycle.
Namespacing conventions
A namespace is a flat string:
byline-admin— the built-in admin shell.byline-<package>— every other Byline-shipped package (byline-richtext-lexical,byline-ai, …).<your-org>-<plugin>— third-party plugins (package name with@//flattened).
Hierarchical keys inside a namespace are dot-separated (chrome.sidebar.collapse, forms.validation.required). Convention only — the runtime treats keys as opaque strings.
Message formatting
intl-messageformat is the floor — the same library the host already uses. Supports plurals ({count, plural, one {# message} other {# messages}}), selects ({gender, select, …}), dates/times/numbers ({date, date, medium}, {n, number, ::percent}), and nesting. The formatter is built once per (locale, namespace, key) and cached for the registry's lifetime — the parse step is the expensive part, and the registry is immutable, so the cache is safe.
Validation messages
Schemas in @byline/core/validation emit stable codes (e.g. password.tooShort) instead of free-form English; the translateValidationError(t, message) helper in @byline/admin/react maps the codes onto the active locale at render time. This keeps @byline/core i18n-agnostic — codes from core, mapping in admin. Form-level Zod defaults (min/max/regex) are translated via the schema-inside-component + useMemo([t]) pattern used across the drawer forms.
Bundling and code-splitting
For now every locale's bundle is part of the initial admin JS payload (two locales × ~5 kB gzipped ≈ 10 kB — well below where code-splitting earns its complexity). Because the bundle map uses static import enJson from './en.json' statements, the bundler sees a fixed-size set at build time. Once a project ships more than ~5 locales, lazy locale loading earns its complexity (see Remaining work).
Worked example: the custom media list view
The webapp's media collection ships a custom listView that replaces the default table with a card grid. It doubles as the canonical worked example for the i18n extension surface — every moving part of the registration API exercised in a setting that does not touch @byline/admin's internals.
apps/webapp/byline/collections/media/i18n/├─ en.json├─ fr.json└─ index.ts ← exports `mediaAdminTranslations({ locales })` factory ← also exports MEDIA_ADMIN_NAMESPACE ('webapp-media-admin')The factory mirrors adminTranslations() — same shape, same validation, same TranslationBundle output:
// apps/webapp/byline/collections/media/i18n/index.tsimport type { LocaleCode, NamespaceTranslations, TranslationBundle } from '@byline/i18n'import { mergeTranslations } from '@byline/i18n'
import en from './en.json'import fr from './fr.json'
/** A globally-unique namespace — by convention `<app-or-package-slug>-<purpose>`. */export const MEDIA_ADMIN_NAMESPACE = 'webapp-media-admin'
const BUNDLES: Readonly<Record<LocaleCode, NamespaceTranslations>> = { en: en as NamespaceTranslations, fr: fr as NamespaceTranslations,}
export function mediaAdminTranslations( options: { locales?: readonly LocaleCode[] } = {}): TranslationBundle { const locales = options.locales ?? ['en'] const partials: TranslationBundle[] = [] for (const locale of locales) { const bundle = BUNDLES[locale] if (bundle == null) { throw new Error(`[mediaAdminTranslations] no bundled translation for '${locale}'.`) } partials.push({ [locale]: { [MEDIA_ADMIN_NAMESPACE]: bundle } }) } return mergeTranslations(...partials)}The host wires it once, in apps/webapp/byline/i18n.ts:
import { mergeTranslations } from '@byline/i18n'import { adminTranslations } from '@byline/i18n/admin'
import { mediaAdminTranslations } from './collections/media/i18n/index.js'
export const i18n = { interface: { defaultLocale: 'en', locales: ['en', 'fr'] }, content: { defaultLocale: 'en', locales: ['en', 'fr', 'es', 'de'] }, translations: mergeTranslations( adminTranslations({ locales: ['en', 'fr'] }), mediaAdminTranslations({ locales: ['en', 'fr'] }), ),}And the component uses the namespace via the hook:
import { useTranslation } from '@byline/i18n/react'import { MEDIA_ADMIN_NAMESPACE } from '../i18n/index.js'
export function MediaListView({ data }: ListViewComponentProps) { const { t } = useTranslation(MEDIA_ADMIN_NAMESPACE) return ( <> <IconButton aria-label={t('header.uploadAriaLabel')}>…</IconButton> <Search placeholder={t('toolbar.searchPlaceholder')} /> {data.docs.length === 0 ? <p>{t('empty')}</p> : /* … */} </> )}mergeTranslations is associative and last-writer-wins at the (locale, namespace, key) granularity, with a dev-mode collision warning. Using a distinct namespace (the recommended pattern) avoids collisions entirely. A third-party plugin in a separate package follows the exact same shape — exporting its own { locales } factory the host imports and merges in byline/i18n.ts.
Why not adopt the host i18n outright?
The host pattern in apps/webapp/src/i18n/ is close to the admin system — intl-messageformat, a namespaced bundle, a React provider, a cookie — but it is not the right thing to ship as the admin system:
- It targets the front-end site, not the admin. The host's
lngcookie carries the site-visitor's language; the admin needs its own cookie so editors aren't forced into the visitor's locale. - No per-user storage. Admin editors expect their preference to follow them across machines.
- No extensibility surface. Plugins / extensions / custom fields need a registration path; the host pattern hard-codes its namespaces in one file.
- Wrong package layer. Admin i18n must ship from the richtext / storage / admin packages without depending on a specific host framework.
What the admin system inherits from the host pattern: ICU-via-intl-messageformat, namespace-then-key structure, cookie-as-persistence-medium, and @formatjs/intl-localematcher for Accept-Language negotiation.
Code map (admin interface)
Concern | Location |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
Boot-time validator |
|
Reference registration |
|
Content locales
A content locale is the language a document is published in. Unlike interface locales (a UI-chrome concern), content locales live in the data: each localized: true field stores one value per locale that has one, and the read pipeline resolves a single effective locale per document at read time.
This section covers two related concerns:
- Resolution & fallback — what a read returns when a document is requested in a locale it has not (yet) been translated into.
- Advertising — the editorial control over which content locales a document promotes in
hreflang/ sitemap / the "Also available in…" affordance, and the admin widget that drives it.
Resolution and fallback
The problem it solves
There is no native "document exists in locale X" flag. A logical document is one row; its current state is one version row carrying one status. Locale exists only one level down, as a column on the stored field-value rows: non-localized fields are stored once (under an 'all' sentinel), and localized: true fields store one row per locale that has a value. So "which locales does this document exist in?" is an emergent property of which value rows happen to exist.
Historically Byline had a locale fallback chain for paths but not for field values. A request for /de/news/foo on an untranslated document would resolve the path (the path layer falls back de → default) but return empty localized fields — the UI rendered the slug as a placeholder title over an empty body. Resolution closes that gap with three rules:
- Resolution is per-document, never per-field. A read picks one effective locale for the whole document and renders every field in it — never mixed-locale ("German title, English body") output.
- A requested locale resolves through a fallback chain that always terminates at the default content locale (which must therefore be published first). A read always returns something, and only 404s when the document does not exist at all — never merely because a translation is missing.
- "Available in locale L" is a version-grain fact — a property of a document version's content, computed once at write time and frozen on the immutable version. Keying it to the version (not the document) is what keeps it correct under restore and point-in-time reads.
When is a locale "available"? The completeness rule
Locale L is available on a version iff every localized field path the default locale has a value at also has a value in L.
The check is run status-blind at write time, from the actually-persisted rows, and the result is stored on the version (in the byline_document_version_locales ledger). Two edges fall straight out of the rule:
- A document with no localized fields at all is trivially locale-agnostic — it renders identically everywhere, so it is treated as available in any requested locale (and surfaces
_localeAgnostic: truewith an empty available set). - A partial translation (title in
de, body not) is not available inde→ resolution falls through to the next chain entry → a clean default-locale page. This is rule #1 (no mixed fields) falling out of the model rather than being special-cased.
Because availability is recorded status-blind and keyed by version, status composes at read time for free: a published read resolves the current published version and checks its frozen locale set, so a draft de translation stays invisible until the draft is published — at which point the status flip alone lights de up for published reads, with zero extra writes.
The fallback chain and onMissingLocale
Resolution walks an ordered locale chain and selects the first entry that is available on the document. The chain defaults to [requested, default] (zero-config, matching the path chain) and always terminates at the default content locale. Path resolution and content resolution consume the same chain builder, so a URL and its content can never disagree on the effective locale.
The behaviour is selected by a onMissingLocale: 'empty' | 'fallback' | 'omit' read option:
Value | Detail read | List read |
'empty' | restore the requested locale exactly — localized fields empty where untranslated. This is the admin edit view (empty fields are the signal to use "Copy to Locale"). | render each row in the requested locale exactly. |
'fallback' | resolve the effective locale via the chain and restore all fields in that one locale. Never 404s on a missing translation. | include every matching document; render each in its own effective locale. |
'omit' | return | include only documents available in the requested locale (a cheap indexed check, so pagination / |
Defaults differ by caller, deliberately: the adapter treats an omitted value as 'empty' (the safe exact-match default for internal/direct reads); @byline/client defaults to 'fallback' so application reads "just show something"; the admin editor explicitly passes 'empty' so switching to an untranslated locale leaves fields empty rather than pre-filling them with default-locale text. Populate always forces 'fallback' regardless of the outer policy, so a populated relation tree never has holes.
Named fallback chains (optional)
The [requested, default] chain already delivers the core guarantee. Named intermediate hops (de → fr → default, or regional variants de-AT → de → default) are an additive enrichment: a fallback?: string | string[] slot on each i18n.content.localeDefinitions entry, consumed by the shared chain builder. Zero migration, no behaviour change for installs that don't set it. The slot is reserved; see Remaining work.
Advertising content locales (availableLocales)
Resolution decides what is renderable. Advertising is the separate, editorial decision of what is promoted — which content-locale URLs appear in hreflang, the sitemap, and the per-page "Also available in…" menu. A document can be renderable in de via fallback yet not promoted as a German page (placeholder copy, mid-edit, legal review).
This is the availableLocales system attribute, opted into per collection with advertiseLocales: true on its CollectionDefinition (valid only when the collection has at least one localized field). It is the deliberate counterpart to the automatic structural fact:
what | source | mutability | |
_availableVersionLocales | "this version is complete in these locales" | the completeness ledger | derived, read-only |
availableLocales | "I want these locales advertised" | the editorial attribute | editor-set, stored |
They must stay separate. A version can be structurally complete in de while the editor does not consider it ready to advertise; conversely the editorial set could name a de that is no longer complete. So the public advertised set is the intersection:
advertised = availableLocales (editorial) ∩ _availableVersionLocales (ledger)This handles both failure modes — complete-but-not-blessed (editorial off ⇒ out) and blessed-but-no-longer-complete (ledger drops it ⇒ out). The host computes this intersection (advertisedLocalesFor in apps/webapp/src/lib/alternates.ts); whether core should expose a pre-reconciled _advertisedLocales directly is left open (see Remaining work).
The widget: a "ready" reconciliation grid
When a collection opts in, Byline renders an available-locales widget in the editor sidebar (directly below the path widget). It shows, per content locale, the structural ledger fact beside the editor's toggle — so the editor is deciding advertise / hold back at exactly the moment the information is in front of them, rather than reacting to a passive boot/save warning:
ledger ( | toggle | state |
✓ complete | on | advertised |
✓ complete | off | ready, held back (the safe state) |
✗ incomplete | off | nothing to do |
✗ incomplete | on | ⚠ advertising an incomplete locale |
The reconciliation is expressed purely through the checkbox's intent colour — no per-row text:
- green / enabled when the locale is complete in the ledger (the editor can toggle it on to advertise);
- neutral / disabled when the locale is not yet complete (nothing to advertise);
- amber / enabled for the ⚠ case — advertised but no longer complete — so the editor can uncheck to resolve.
That green checkbox is the visible output of the "locale ready" detection: the completeness rule above, which inspects every localized field for a saved value in that locale at write time and records the result on the version. The widget never re-derives it in the browser; it reads _availableVersionLocales off the edit payload and lights the row green when the locale is present. The policy is opt-in — nothing is advertised until the editor checks a green locale.
For the widget to render the ledger column, the admin edit response preserves_availableVersionLocalesacross its Zod parse (which would otherwise strip the unknown key), alongsideavailableLocalesitself.
What core surfaces on a read
Per read, core emits the facts and stops there — the host turns them into URLs and tags:
Field | Meaning |
| the editorial advertised set (document-grain, stored). |
| the structural completeness ledger for the resolved version (derived, read-only, sorted). |
|
|
| the document's content anchor — see Administering content locales. |
(the effective locale) | which content locale the document actually resolved to, driven by |
Because @byline/client defaults to status: 'published' and the ledger resolves against the current published version, _availableVersionLocales on a normal read is the published-available set — exactly what a public consumer should advertise. These fields unify three host consumers — hreflang, sitemap.xml, and the "Also available in…" menu — on one source, so they cannot drift.
Code map (content locales)
Concern | Location |
Locale chain builder + effective-locale resolution |
|
Completeness ledger write + |
|
|
|
|
|
|
|
Read-surface shaping ( |
|
Available-locales widget + "ready" reconciliation |
|
Edit-payload preservation of |
|
Host advertised-set resolver |
|
Re-import that establishes the advertised set |
|
Ledger backfill for pre-existing versions |
|
Administering content locales
Switching the default content locale
A system's default content locale (i18n.content.defaultLocale) does two different jobs:
- A config preference — which locale new content is authored in, and which locale is served for a request that doesn't specify one. Genuinely global, and genuinely should be switchable.
- A per-document data anchor — every document's content rows, its path row, and its completeness ledger were originally written keyed to whatever the default was at write time.
Job (2) is the trap. If the default were only a global config value, flipping en → fr on a live system would silently re-interpret every existing document against an anchor it was never written for: en-authored fields would read empty (the fallback floor moves to fr, which is empty), findByPath(slug, 'fr') would 404 (path rows live under en), and the completeness yardstick would become meaningless. Non-localized content (the 'all' sentinel) and explicit 'en' reads are unaffected — but everything anchored to the default breaks.
The fix: a per-document source_locale
Byline records a per-document source_locale on byline_documents, set once at creation to the locale the first version was authored in (defaulting to the global config default at that moment). It re-bases each anchor — the fallback floor, the path locale, the completeness yardstick — from "the global config" to "this document's own truth." With that column in place, switching i18n.content.defaultLocale is a non-event for existing data: every document rides its own source_locale, and the global default is demoted to its honest role — the authoring default for new documents plus the request-time fallback when a read specifies no locale.
source_locale is surfaced on every read payload as sourceLocale, and the editor shows it as a small neutral badge next to the document title (see Remaining work for making that badge mismatch-only). For in-place upgrades, the column is populated at boot — initBylineCore() stamps any unstamped rows with the configured default idempotently — so a vanilla drizzle:migrate never fails on a constraint and upgrades self-heal.
Re-anchoring documents onto the new default
Switching the config is safe immediately, but the harder part of actually moving documents onto the new default is having them fully translated into it — the system can never manufacture a primary language with holes. So the realistic workflow is: flip the config → translate documents into the new locale over time → re-run the bulk re-anchor to sweep up the now-complete ones.
The bulk re-anchor is a script (apps/webapp/byline/scripts/re-anchor.ts):
pnpm tsx byline/scripts/re-anchor.ts --to fr [--collection <path>] [--dry-run]Per document, in one transaction, it: skips documents that are not-found, already-anchored, or incomplete in the target locale (eligibility comes from the completeness ledger — it refuses to manufacture a translation); otherwise flips source_locale, moves the path row onto the target locale (re-tagging the slug, keeping the URL stable), and writes a new immutable version recomputing its ledger against the new anchor. Each document is its own transaction, so the operation is idempotent and resumable — and --dry-run reports the would-be outcome plus the backlog. The skipped-incomplete report is your outstanding-translation list.
A per-document interactive "Set primary language" action (in the DocumentActions dropdown, gated behind a stronger permission than plain edit) is a planned follow-up — see Remaining work. The bulk command already covers the system-switch use case.Remaining work
Notable TODOs left across the i18n surface, roughly by area. None are load-bearing for current functionality.
Admin interface
- Translation drift detection in CI — a check comparing key sets across bundled locales (enumerate every
(locale, namespace, key)triple; ensure each namespace's key set matches across locales). A warning, not an error — partial translations are fine, but contributors should see the drift. Worth landing before a third or fourth bundled locale arrives. - Date/number locale separate from message locale — today the formatter uses the active locale for both message lookup and
{date, date, medium}. Splitting them (e.g. a Spanish-language admin who prefers ISO-8601 dates) would be a per-user setting; surface it if a request emerges. - Lazy locale loading — replace the eager static-import bundle map with async loaders once a host ships more than ~5 locales.
- Authoring-time metadata — an opt-in registration shape carrying per-key metadata (source description, plural-form hint, source-string fingerprint) to power external translation-service integration (Crowdin / Lokalise / Phrase) and a "missing translations" admin tile. The simple
{ key: string }shape stays the floor. - RTL support — right-to-left locales need the admin shell's CSS to flip, not just translated strings. Worth a dedicated design pass once a community RTL locale lands and surfaces the real breakages.
- Inline translation editing — an "edit translations in the admin UI" affordance (click a missing string, type it inline, write back to bundle files). Powerful but a significant build; intentionally out of scope, tracked here so the question doesn't get re-opened ad hoc.
Content locales
- Named fallback chains — wire the reserved
fallback?slot oni18n.content.localeDefinitionsinto the shared chain builder (de → fr → default, regional variants). Zero migration; land it when a regional-variant or editorial-fallback need actually appears. - Advertising / availability cross-check — a save- or boot-time warning when the editorial
availableLocalesset and the version's completeness set disagree. - Core-computed advertised set — decide whether core should expose a pre-reconciled
_advertisedLocales(theavailableLocales ∩ _availableVersionLocalesintersection) so a host consumes one field instead of computing the intersection itself. - sitemap.xml reference route — not shipped in the example app; it would consume the same
resolveAlternatesresolver as thehreflangmeta, so the two can't drift.
Administering content locales
- source_locale NOT NULL constraint — the column is intentionally nullable (boot-time backfill + COALESCE fallbacks) to avoid a
drizzle:migrateordering footgun on in-place upgrades. Once every live install has booted on a release that stamps legacy rows, a later release can add the hard constraint. A guarded, idempotentALTER ... SET NOT NULLscript ships inpackages/db-postgres/sql/set-source-locale-not-null.sql; when it becomes the norm, also flip the Drizzle schema column to.notNull()and regenerate. - Per-document interactive re-anchor — a "Set primary language" action in the
DocumentActionsdropdown (precedent: Copy to Locale): a target picker fed by_availableVersionLocales(incomplete locales disabled), a confirmation modal, and a permission above plain edit (open: a newcollections.<path>.manageLocaleability vs admin-only). The bulk command covers the system-switch case today. - Mismatch-only source-locale badge — the edit-view badge currently shows for every document (so the anchor is visible during development). The end state is to show it only when a document's
source_localediffers from the system's current default. A list-view badge is likewise deferred (sourceLocaleis already carried on the list response, so it's mismatch-only rendering with no further data work).