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:
| # | Source | Example |
|---|---|---|
| 1 | A /<locale> path prefix | /fr/about → fr |
| 2 | Best Accept-Language match among your locales | Accept-Language: fr-CA → fr |
| 3 | The configured defaultLocale | → en |
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')readsnav.home. - Interpolates
{param}placeholders fromparams; an unknown placeholder is left intact. - Falls back: a key missing in the active locale tries the
defaultLocale, then returns the key itself.
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.
$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' }
| Export | Signature | Returns |
|---|---|---|
createI18n | { messages, locale, defaultLocale, onMissingKey? } | { locale, t } |
resolveLocale | { path, acceptLanguage, locales, defaultLocale } | { locale, path } |