Extensions
Internationalization (i18n)
One i18n layer for the backend and the frontend. Drop your catalogs in any i18n directory, 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/i18nThen add one decorator. That is the whole setup: it registers the service provider (so constructor ({ i18n }) injects it anywhere), installs the middleware that resolves the request locale, and lets the build discover your catalogs on its own. Everything below is optional.
import { I18n } from '@stone-js/i18n'
import { StoneApp } from '@stone-js/core'
@I18n({ locales: ['en', 'fr'], fallbackLocale: 'en' })
@StoneApp({ name: 'my-app' })
export class Application {}The imperative equivalent hands the blueprint to defineStoneApp, exactly where the decorator form lists it.
import { defineStoneApp } from '@stone-js/core'
import { i18nBlueprint } from '@stone-js/i18n'
export const Application = defineStoneApp(handler, { name: 'my-app' }, [i18nBlueprint])#Zero-config layout
A catalog is any directory named i18n, holding <locale>/<namespace>.json files. The locale and namespace are read from the path, so no manifest is needed. The simplest project keeps one catalog.
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" }#Catalogs at any depth
Catalogs are found anywhere under app, so a larger codebase can keep translations next to the code that uses them instead of in one growing directory.
app/
i18n/ <- shared across the app
en/common.json
fr/common.json
modules/
billing/
BillingService.ts
i18n/ <- owned by the billing module
en/invoice.json
fr/invoice.json
crm/contacts/i18n/fr/contact.jsonEvery catalog contributes, and catalogs sharing a locale and a namespace merge deeply: several modules can each add their own keys to a shared common namespace. On a conflicting key the deeper catalog wins, so the result is identical on every machine and every build. node_modules and dotted directories are never scanned, because a dependency's translations are not yours.
#Loading catalogs
The recommended path is the CLI plugin: at build time it walks app for every i18n directory 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 defineBuilderConfig({ 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 defineBuilderConfig({ plugins: [i18nCliPlugin({ lazy: false })] })#When the convention does not fit
Four options, from the least to the most explicit. A conventional project needs none of them.
| Option | Type | Description |
|---|---|---|
| root | 'app' | The directory walked for catalogs. |
| dirname | 'i18n' | The directory name that marks a catalog, for example 'locales'. |
| dir | string | Scan exactly this one directory, no walk, for translations kept outside root. |
| pattern | glob | Take the files from a glob instead of the walk, when nothing above fits. |
// Catalogs named `locales/` instead of `i18n/`, anywhere under `src`
export default defineBuilderConfig({ plugins: [i18nCliPlugin({ root: 'src', dirname: 'locales' })] })
// Full control, for a layout no convention describes
export default defineBuilderConfig({ plugins: [i18nCliPlugin({ pattern: 'packages/*/translations/*/*.json' })] })Whatever a pattern matches must still end in <locale>/<namespace>.<ext>: that tail is how the runtime knows which locale and namespace a file carries.
#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 { defineConfig } from '@stone-js/core'
import { loadTranslations } from '@stone-js/i18n'
export const AppConfig = defineConfig((blueprint) => {
blueprint.set('stone.i18n.locales', ['en', 'fr'])
blueprint.set(
'stone.i18n.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((blueprint) => blueprint.set('stone.i18n', {
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)
}))