Localization
@rudderjs/localization translates strings against JSON language files. It supports named interpolation, plural rules, fallback locales, and request-scoped locale switching via AsyncLocalStorage — so a Spanish-speaking user's request renders Spanish strings without leaking that locale to other in-flight requests.
Setup
pnpm add @rudderjs/localizationCreate JSON language files:
lang/
├── en/
│ └── messages.json
├── es/
│ └── messages.json
└── ar/
└── messages.json// lang/en/messages.json
{
"welcome": "Welcome to :app!",
"greeting": "Hello, :name!",
"items": "{0} no items|{1} one item|{n} :count items"
}// config/localization.ts
import { resolve } from 'node:path'
import { Env } from '@rudderjs/support'
export default {
locale: Env.get('APP_LOCALE', 'en'),
fallback: 'en',
path: resolve(process.cwd(), 'lang'),
}The provider is auto-discovered.
Translating
import { trans } from '@rudderjs/localization'
await trans('messages.welcome', { app: 'Rudder' }) // 'Welcome to Rudder!'
await trans('messages.greeting', { name: 'Alice' }) // 'Hello, Alice!'
await trans('messages.items', 3) // '3 items'For sync lookups (cache-only, returns the key on miss), use __():
import { __ } from '@rudderjs/localization'
__('messages.greeting', { name: 'Alice' })Always use
trans()inside Vike+data.tsfiles —__()is sync and won't load a namespace that hasn't been touched yet. Inside route handlers and middleware,__()is fine after the namespace has been loaded once.
Pluralization
Two shapes are supported:
{
"apple_count": "one apple|many apples",
"items": "{0} no items|{1} one item|{n} :count items"
}await trans('messages.items', 0) // 'no items'
await trans('messages.items', 1) // 'one item'
await trans('messages.items', 12) // '12 items'{0} and {1} match those exact counts; {n} is the fallback for any other count. The two-form one|many shape selects one for a count of 1 and many otherwise. Counts pass as the second argument; if you need both a count and named placeholders, pass { count: 3, name: 'Alice' }.
Per-request locale
Per-request locale switching is AsyncLocalStorage-based, but the API is runWithLocale(locale, fn) (the scoped form), not setLocale() (which mutates the global default).
The simplest path is to mount LocalizationMiddleware() — it reads Accept-Language and wraps the rest of the request in runWithLocale() automatically:
import { LocalizationMiddleware } from '@rudderjs/localization'
.withMiddleware((m) => {
m.use(LocalizationMiddleware())
})To switch locales mid-request (e.g. inside a sign-in flow that learns the user's preferred locale from the database), call runWithLocale():
import { runWithLocale, getLocale } from '@rudderjs/localization'
Route.post('/login', async (req) => {
const user = await authenticate(req)
return runWithLocale(user.preferredLocale, async () => {
return view('dashboard.welcome', {
currentLocale: getLocale(), // user's locale, scoped to this callback
})
})
})setLocale('es') exists too, but it only changes the locale for the current request context (the active runWithLocale() / middleware scope). Outside such a context it is a no-op, so it does not provide a process-wide override — set locale in config/localization.ts for the default.
Multiple namespaces
Each filename in lang/<locale>/ is a namespace. Reference keys with namespace.key:
lang/en/auth.json → trans('auth.login.success')
lang/en/messages.json → trans('messages.welcome')
lang/en/validation.json → trans('validation.required')The framework loads namespaces lazily on first access — trans('messages.welcome') reads messages.json once and caches it.
Validation messages
If lang/<locale>/validation.json exists, @rudderjs/core's validator uses it for error messages:
{
"required": ":field is required.",
"email": ":field must be a valid email address."
}The validator passes :field automatically. See Validation.
Pitfalls
__()in+data.ts. Sync lookup miss returns the key, not the translation. Usetrans()(async) so the namespace can load on first access.- Locale leaking across requests. Don't call
setLocale()per-request — it mutates the global default, not request-scoped state. UseLocalizationMiddleware()(auto) orrunWithLocale()(manual) for per-request scoping. - Plural matching order. Specific counts (
{0},{1}) must come before the{n}fallback — the matcher takes the first matching segment.