Extensions
Internationalization (i18n)
One i18n layer for the backend and the frontend. Drop your catalogs in app/i18n, and the request locale is resolved and scoped for you: on the server every request gets its own translator, concurrency-safe; in the browser you switch the active locale in one call. Translation runs on i18next; numbers, dates and lists use the native Intl APIs.
#Install
npm i @stone-js/i18nRegister the opt-in i18nBlueprint in your app, then configure throughstone.i18n or defineI18n. Everything below is optional.
#Zero-config layout
Lay out catalogs as app/i18n/<locale>/<namespace>.json. The locale and namespace are read from the path, so no manifest is needed.
app/i18n/
en/
common.json { "hello": "Hello {{name}}!" }
cart.json { "items_one": "{{count}} item", "items_other": "{{count}} items" }
fr/
common.json { "hello": "Bonjour {{name}} !" }
cart.json { "items_one": "{{count}} article", "items_other": "{{count}} articles" }#Loading catalogs
The recommended path is the CLI plugin: it scans app/i18n at build time and generates the wiring with plain imports, so the same setup works on a backend service (Rollup), a browser SPA and SSR (Vite) alike. No wiring line in your code.
import { i18nCliPlugin } from '@stone-js/i18n/cli'
// stone.config.mjs
export default defineConfig({ plugins: [i18nCliPlugin()] })Because @stone-js/i18n is first-party, it is also auto-discovered from your direct dependencies (announced on every build). Opt out with autoDiscoverPlugins: false. See Participate in the build for how the plugin system works.
#Lazy by default, no FOUC
Catalogs are lazy by default: only the active locale's catalog is imported, code-split per file, for a lighter payload. The locale is resolved in a kernel middleware that runs before your handler, and it awaits the catalog import, so the first render already has its translations: there is no flash of untranslated keys and no layout shift. Pass lazy: false to bundle every locale eagerly instead.
// Lazy is the default. To bundle every locale eagerly instead:
export default defineConfig({ plugins: [i18nCliPlugin({ lazy: false })] })#By hand
Prefer to wire it yourself? Set stone.i18n.resources. On Vite targets (SPA, SSR),import.meta.glob autoloads them, isomorphic and tree-shaking. For a plain backend service, prefer the plugin: it emits static imports rather than import.meta.glob, which only Vite understands.
import { defineI18n, loadTranslations } from '@stone-js/i18n'
export const AppConfig = defineConfig(defineI18n({
locales: ['en', 'fr'],
resources: loadTranslations(import.meta.glob('/app/i18n/**/*.{json,ts,js,yaml,yml}', { eager: true }))
}))#Per-request locale
The locale is resolved automatically, in order (first match wins), each candidate negotiated against locales (fr-CA becomes fr):
| Order | Type | Description |
|---|---|---|
| 1. resolver | custom | A custom resolver function, if you provide one. |
| 2. route param | :lang | A path-based locale when `param` is set and the router is available (isomorphic). |
| 3. headers | x-locale | Custom headers x-locale, then x-lang, then x-language. |
| 4. query | ?lang= | The lang query parameter. |
| 5. cookie | locale | The locale cookie. |
| 6. Accept-Language | header | The standard Accept-Language header. |
| 7. fallback | default | The event's own locale, then fallbackLocale. |
#Translate and format
On the server, read the request-bound translator from the event with translatorFor(never mutates shared state, so it is safe under concurrency). Or inject the service (constructor ({ i18n })) and bind a locale with i18n.forLocale(locale).
import { translatorFor } from '@stone-js/i18n'
import { EventHandler, Get } from '@stone-js/router'
@EventHandler('/greet')
export class GreetController {
@Get('/')
greet (event) {
const t = translatorFor(event) // bound to the request locale, concurrency-safe
return {
message: t.t('hello', { name: 'Ada' }), // "Bonjour Ada !" for a fr request
items: t.t('items', { ns: 'cart', count: 3 }), // ICU pluralization
price: t.currency(19.9, 'EUR'), // "19,90 €"
reach: t.compact(1_500_000) // "1,5 M"
}
}
}Formatting is native Intl, locale-aware:
| Helper | Type | Description |
|---|---|---|
| t(key, opts) | string | Interpolation, ICU pluralization (count), namespaces, per-call locale. |
| number / compact | string | 1500000 becomes "1,500,000" or the compact "1.5M". |
| currency / percent | string | currency(19.9, "EUR") and percent(0.25). |
| date / relativeTime | string | Time-zone aware dates and "in 3 days". |
| list | string | "a, b and c" for the active locale. |
| dir(locale?) | ltr | rtl | Writing direction, for the <html dir> attribute. |
#On the frontend
The same API runs in the browser. Switch the active locale in one call, and set the document direction from dir(). For React components, i18n.raw exposes the underlying i18next instance, so you can wire react-i18next directly if you want.
// A locale switcher in the browser
await i18n.setLocale('fr') // re-renders in French
document.documentElement.dir = i18n.dir() // 'ltr' | 'rtl' for the <html> element#Configure
Everything is optional, under stone.i18n.*:
export const AppConfig = defineConfig(defineI18n({
locale: 'en', // active locale
locales: ['en', 'fr', 'ar'], // negotiated set (fr-CA -> fr)
fallbackLocale: 'en', // used for missing keys
defaultNamespace: 'translation',
timeZone: 'America/New_York', // default for date formatting, per-call overridable
param: 'lang', // resolve the locale from a :lang route param
onMissingKey: (key, locale, ns) => console.warn('missing', locale, ns, key)
}))