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:

TSsrc/i18n/index.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>;
TSsrc/i18n/messages/en.ts
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:

  1. JANUX_LOCALE cookie — set it from your language switcher to make the choice sticky.
  2. accept-language header — exact match first, then base-language match (ptpt-BR).
  3. 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:

TSXsrc/routes/index.tsx
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 error

Static 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 automatic hreflang yet.
  • interpolation.format functions run on the server; islands interpolate client-side without custom formatters.
  • Try it live: examples/i18n.