Internationalization

One app, every language.

Apex resolves a locale for every request — from a /<locale> URL prefix or the browser's Accept-Language — and hands your loaders a t() translator and the active locale. Catalogs are plain JSON, the SSR shell sets <html lang>, and the runtime is client-safe.

Setup #

Declare your default and supported locales in apex.config.ts:

apex.config.ts

import { defineConfig } from '@apex-stack/core'

export default defineConfig({
  i18n: { defaultLocale: 'en', locales: ['en', 'fr'] },
})

Put one catalog per locale in locales/<locale>.json. Nested objects are allowed — you address them later with dotted keys:

locales/en.json

{
  "welcome": "Welcome, {name}",
  "nav": { "home": "Home" }
}

locales/fr.json

{
  "welcome": "Bienvenue, {name}",
  "nav": { "home": "Accueil" }
}

Locale resolution #

Every request picks exactly one locale, in this order:

#SourceExample
1A /<locale> path prefix/fr/aboutfr
2Best Accept-Language match among your localesAccept-Language: fr-CAfr
3The configured defaultLocaleen
Only configured prefixes count

A prefix you didn't configure — say /de when locales is ['en', 'fr'] — is not treated as a locale. It 404s rather than silently falling back to the default, so a typo'd or unsupported language never resolves to the wrong page.

Translating #

The v1 pattern translates on the server. locale and t are injected into locals for page loaders, head(), and middleware — so translated strings arrive as ordinary loader data:

pages/index.alpine

<script server lang="ts">
export function loader({ locals }) {
  return {
    greeting: locals.t('welcome', { name: 'Ada' }),
    home: locals.t('nav.home'),
  }
}
</script>
<template x-data>
  <h1 x-text="greeting"></h1>
</template>

t(key, params?) does three things:

  • Resolves dotted keys against nested catalogs — t('nav.home') reads nav.home.
  • Interpolates {param} placeholders from params; an unknown placeholder is left intact.
  • Falls back: a key missing in the active locale tries the defaultLocale, then returns the key itself.
Missing keys are loud in dev, silent in prod

In development a missing key logs a console.warn (through the onMissingKey hook) so gaps surface while you build. Production stays silent and just uses the fallback chain above.

Routing #

Routes match the locale-stripped path. /fr/about resolves pages/about.alpine and renders it in French — you don't duplicate a page per locale. Nested and dynamic [param] routes work under the prefix exactly as they do without it.

On the client #

The SSR shell sets <html lang="<locale>"> and seeds window.__APEX_LOCALE__ so the active locale is available to client code and to assistive tech and search engines.

Template-side $t() is coming

Because v1 translates in the loader, translated strings hydrate as plain data — no client bundle of catalogs required. A template-side $t() magic (translate directly in markup) is a planned follow-up; for now, resolve strings in the loader and bind them with x-text.

Runtime API #

The two primitives that power the above are exported from @apex-stack/core and are client-safe, so you can reuse them for custom cases:

import { createI18n, resolveLocale } from '@apex-stack/core'

// Build a translator for a known locale + messages
const { locale, t } = createI18n({
  messages,                 // { en: {...}, fr: {...} }
  locale: 'fr',
  defaultLocale: 'en',
  onMissingKey: (k) => console.warn(`missing: ${k}`),  // optional
})
t('welcome', { name: 'Ada' })

// Pick a locale from a request
const { locale: loc, path } = resolveLocale({
  path: '/fr/about',
  acceptLanguage: 'fr-CA,fr;q=0.9,en;q=0.8',
  locales: ['en', 'fr'],
  defaultLocale: 'en',
})   // → { locale: 'fr', path: '/about' }
ExportSignatureReturns
createI18n{ messages, locale, defaultLocale, onMissingKey? }{ locale, t }
resolveLocale{ path, acceptLanguage, locales, defaultLocale }{ locale, path }