Internationalization (i18n)#
Janux has built-in internationalization, ported from Brisa's battle-tested transCore (the same engine behind next-translate): locale-prefixed routing, a t() function with plurals, interpolation and nested keys, type-safe translation keys — and a client payload that ships only the translations the current page's islands actually consume. Server-only pages pay 0 extra bytes.
Setup#
Add a src/i18n.ts (or src/i18n/index.ts) that default-exports an I18nConfig. Its presence activates i18n — there is nothing to switch on in janux.config.ts:
import type { I18nConfig } from 'janux';
import en from './messages/en';
import es from './messages/es';
export type Messages = typeof en;
export default {
locales: ['en', 'es'],
defaultLocale: 'en',
messages: { en, es },
} satisfies I18nConfig<Messages>;export default {
home: { title: 'Welcome', lead: 'Rendered in {{locale}}' },
cart: { items_one: '{{count}} item', items_other: '{{count}} items' },
};The satisfies I18nConfig<typeof en> line does double duty: every other locale must match the shape of your default-locale messages, and t() keys become compile-checked (see Type-safe keys).
Config options#
| Option | Default | Purpose |
|---|---|---|
locales |
— | Supported locale codes ('en', 'pt-BR', …) |
defaultLocale |
— | Used when detection finds no match |
messages |
{} |
One dictionary per locale |
keySeparator |
'.' |
Nested-key separator (t('a.b')) |
allowEmptyStrings |
true |
'' translations render empty instead of falling back to the key |
interpolation.prefix / .suffix |
'{{' / '}}' |
Variable delimiters |
interpolation.format |
— | (value, format, locale) => string formatter for {{price, currency}}-style params (server-side rendering only) |
Routing#
Every page lives under its locale prefix: src/routes/about.tsx serves /en/about, /es/about, … Requests without a prefix are redirected (302) to the detected locale:
JANUX_LOCALEcookie — set it from your language switcher to make the choice sticky.accept-languageheader — exact match first, then base-language match (pt→pt-BR).defaultLocale.
Janux also sets <html lang> and <html dir> (rtl for Arabic, Hebrew, …) automatically, and _janux/* endpoints, llms.txt and static assets are never localized.
Links rewrite themselves. Write <a href="/about"> anywhere and the server renders href="/es/about" for a visitor on /es. An href that already starts with a locale is left untouched — that exception is the language switcher:
// A language switcher is just links with explicit locale prefixes:
{locales.map((code) => (
<a key={code} href={`/${code}${path === '/' ? '' : path}`}>{code}</a>
))}Switching locale is a plain (soft) navigation: the server renders the page in the new language and the SPA diff swaps content, <html lang> and the translation payload in place.
Consuming translations#
ctx.i18n carries { locale, locales, defaultLocale, t } everywhere a ctx flows: pages, meta, server components (pass ctx down as a prop) and island views (bag.ctx). The getI18n accessor adds typing and a clear error when i18n isn't configured:
import { getI18n, type Ctx } from 'janux';
import type { Messages } from '../i18n';
export const meta = ({ ctx }: { ctx: Ctx }) => ({ title: getI18n(ctx).t<string>('home.title') });
export default function Home({ ctx }: { ctx: Ctx }) {
const { t, locale } = getI18n<Messages>(ctx);
return <h1>{t('home.title')}</h1>;
}Inside an island it's the same, via the view bag:
const Cart = component({
name: 'cart',
state: schema({ items: list({ id: str() }) }),
view: ({ state, ctx }: any) => {
const { t } = getI18n<Messages>(ctx);
return <output>{t('cart.items', { count: state.items.length })}</output>;
},
});The t() API#
t('home.title'); // nested keys via keySeparator
t('home.lead', { locale: 'en' }); // {{variable}} interpolation
t('cart.items', { count: 3 }); // plurals from query.count
t('missing', null, { fallback: ['other-key'] });
t('missing', null, { default: 'Shown when nothing matches' });
t('home', null, { returnObjects: true }); // the whole subtree as an object
t('terms', null, { elements: [<a href="/legal" />] }); // 'See <0>the terms</0>'Plurals resolve from query.count using Intl.PluralRules, trying key_3 (exact number), key_other-style suffixes, then the nested forms key.3 / key.other:
// messages: { items_0: 'None', items_one: '{{count}} item', items_other: '{{count}} items' }
t('items', { count: 0 }); // 'None'
t('items', { count: 2 }); // '2 items'JSX in translations: elements replaces <0>…</0> (array) or <tag>…</tag> (record) markers with real elements — the translation stays one string for translators.
What ships to the client#
Pages without islands embed nothing. Pages with islands embed one <script type="application/janux+i18n"> holding the current locale plus a filtered dictionary:
- every key an island resolved while server-rendering (recorded automatically), including its plural variants and nested subtree, and
- every key matching an island's declared
i18nKeys.
The client runtime rebuilds ctx.i18n.t from that payload on boot and refreshes it after each SPA navigation, so islands re-render with translations after interaction — without ever downloading the full dictionaries.
i18nKeys: keys used only after interaction#
SSR recording can't see branches that never rendered. If an island shows a translation only after the user does something, declare it (exact key, prefix string, or RegExp):
const Counter = component({
name: 'counter',
// Rendered only after the fifth click — SSR never records it:
i18nKeys: ['counter.milestone'],
// i18nKeys: [/^counter\./] also works
...
});Re-keying islands across locale switches#
An island whose DOM node survives a soft navigation keeps its mounted instance — and its state. On a locale switch you usually want a fresh instance instead, so key the island by locale:
<Counter key={locale} />Type-safe keys#
Parameterize getI18n (or I18n/Translate) with your default-locale messages and invalid keys fail to compile, with IDE autocompletion for the valid ones. Nested keys are joined with . and plural suffixes collapse to their base key:
const { t } = getI18n<Messages>(ctx);
t('home.title'); // ✅
t('cart.items'); // ✅ (cart.items_one/_other collapse to cart.items)
t('home.titel'); // ❌ compile errorStatic export#
With output: 'static', janux build prerenders every page per locale (dist/client/es/about/index.html, …) and emits a root index.html that redirects to the visitor's language (meta-refresh fallback to defaultLocale).
Low-level building blocks#
The framework wires these for you; they're exported from janux for custom setups, tests, or reuse outside a Janux server:
| Export | Signature | What it does |
|---|---|---|
translateCore |
(locale, config) => t |
Builds the t() function for one locale — plurals (Intl.PluralRules), {{interpolation}}, nested keys, fallbacks. This is what powers ctx.i18n.t. |
formatElements |
(value, elements) => string | unknown[] |
Replaces <0>…</0> / <tag>…</tag> markers in a translated string with real JSX elements. Unmatched tags render only their content, so a missing element never leaks markup. This is the engine behind the elements option. |
selectMessages |
(dic, used, declared?, sep?) => dic |
Filters a locale's dictionary down to the keys a page actually consumes (SSR-recorded keys + their plural/nested variants + declared i18nKeys). This is what produces the filtered client payload. |
getI18n |
(ctx) => I18n |
Typed accessor for ctx.i18n with a clear error when i18n isn't configured (used throughout this guide). |
import { translateCore, formatElements, selectMessages } from 'janux';
const t = translateCore('es', config); // t('home.title')
const slim = selectMessages(config.messages.es, usedKeys, ['counter.']);Current limitations#
- Route pathnames are not translated (
/es/about, not/es/sobre-nosotros), and there is no domain-based routing or automatichreflangyet. interpolation.formatfunctions run on the server; islands interpolate client-side without custom formatters.- Try it live:
examples/i18n.