The host i18n system
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 required $lng route segment 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).
Clean default-locale URLs via an isomorphic rewrite
$lng is a required segment internally, but the default-locale prefix is never visible in the address bar. An isomorphic URL-rewrite pair (src/i18n/locale-rewrite.ts, wired into createRouter({ rewrite })) runs on both the SSR request-parse and client navigation:
- input prepends the default locale to a bare frontend path so the matcher always sees a locale segment — skipping non-localized siblings (
_byline'sadmin/sign-in, plus_serverFn,_build,uploads,api) and static assets. - output strips only a leading default-locale segment → clean URLs for
en. The load-bearing invariant is de-DEFAULT, never de-LOCALIZE: non-default interface and content locales (fr,zh-CN, …) stay visible, because the prefix drives content rendering, thehreflangself-reference, and the canonical.locale-rewrite.test.tspins it.
Locale negotiation (cookie / Accept-Language → redirect) and canonicalisation (an externally-typed /en/… → 301 to the clean form) live in the server entry (src/server.ts → src/i18n/server-locale-redirect.ts), not in the rewrite: the rewrite runs before route middleware, can't read cookies on the client, and has already hidden whether a URL arrived bare. (This arrangement replaced an earlier optional {-$lng} matcher plus per-locale route shims / virtual routes — the rewrite removes that machinery while keeping clean default-locale URLs.)
The non-sticky rule
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 across coordinated places:
- Server-entry negotiation (
src/i18n/server-locale-redirect.ts) — only an interface-locale preference (cookie +Accept-Language) negotiates a redirect and writes thelngcookie. A routable content-locale segment passes straight through, without negotiation and without writing 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.
Content locale vs interface locale (chrome), and why chrome is deterministic
Two locales are in play on any URL, exposed as two hooks:
- useLocale() — the path / content locale (may be a content-only locale like
zh-CN). Drives content rendering, meta, canonical, and the per-page content-language affordance's active state. - useInterfaceLocale() — the chrome locale (nav, menus, labels). On an interface-locale URL it equals the path locale; on a content-only URL it falls back to the default interface locale via
toInterfaceLocale().
useInterfaceLocale() is deliberately a pure function of the URL locale — it does not consult the cookie or Accept-Language. This is a caching requirement: a content-only-locale page (/zh-CN/about) is keyed only by its URL on a shared proxy, so its chrome must be deterministic per URL. Resolving chrome from out-of-URL signals would make one URL render different chrome per visitor and poison the cache. The $lng route loader keys its chrome translation bundle off the same toInterfaceLocale, so the loaded bundle and the hook agree by construction.
A French visitor deep-linking to a Chinese content page therefore sees default-locale chrome on that page, reverting to French on their next navigation (the lng cookie is untouched). A deployment that runs a programmable edge and wants personalized chrome on these pages can instead add a normalized interface-locale dimension to its CDN cache key — an ops-only change that leaves the app deterministic-by-default.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.
getServerConfig() / getPublicConfig() pull in the admin graph and are server-only. The leaf locales.ts is the deliberate, bundle-safe seam — import it, not byline/i18n.ts, from public client code.
Reference implementation files
A worked TanStack-Start host, all under apps/webapp/:
Concern | Location |
Routable-locale config, |
|
Isomorphic locale URL rewrite (clean default-locale URLs; de-DEFAULT-never-de-LOCALIZE) |
|
Server-entry negotiation + |
|
Two-axis locale hooks ( |
|
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 sitemap.xml — both derive from, so the two can never drift.