i18n - usage
Within src/utils/i18n.ts, you will find some boilerplate code provided for you which will activate your app's locale configuration. This code uses a util provided by Psychic to provision a curried i18n function for you, which will carefully absorb the locale shape of your config and use it to provide type completion that will make your life much easier.
// src/utils/i18n.ts
import locales from '@conf/locales/index.js'
import { I18nProvider } from '@rvoh/psychic/system'
import { LocalesEnumValues } from '@src/types/db.js'
export function supportedLocales() {
return LocalesEnumValues
}
export default I18nProvider.provide(locales, 'en')
When calling I18nProvider.provide, the first argument will be the entire payload of locales from your application config, and the second argument will be the specific locale that you are treating as your base locale. This is used to provide type completion as you use the i18n helper — i18n() only accepts dotted keys that exist in that base locale file, so pass the most complete one. At call time, the locale you pass is matched to a locale key by its language prefix ('en-US' matches the en locale file), and anything unmatched falls back to the base locale.
// elsewhere, in your app
const text = i18n('en-US', 'labels.nutrition.calories')
We recommend that you leverage i18n within your serializer layer, since this can be an incredibly convenient place to make translations before delivering your endpoint results.
See the passthrough setting documentation for details on how to set passthrough data on automatically rendered serializers.
import { DreamSerializer } from '@rvoh/dream'
import { LocalesEnum } from '@src/types/db.js'
import i18n from '@src/utils/i18n.js'
export const PlaceForGuestsSerializer = (place: Place, passthrough: { locale: LocalesEnum }) =>
PlaceSummaryForGuestsSerializer(place)
.customAttribute('style', () => i18n(passthrough.locale, `places.style.${place.style}`), {
openapi: 'string',
})
.attribute('sleeps')
.rendersMany('rooms', { serializerKey: 'forGuests' })
This is code-driven i18n: translating static values — enum labels, UI strings — that come from your code, not from a user. Treat any user-facing label baked into an enum (a place style, a room type, a status) as an i18n concern from the start, with an entry in your locale files, rather than reaching for an ad hoc Record<EnumValue, string> lookup map next to the enum. A hand-rolled label map duplicates what i18n() already gives you — locale coverage, type-checked keys, and one lookup path for both static and user-generated content — and it silently stops working the moment you add a second locale. For translating user-generated content (a place's title, a listing's description), see passthrough for the LocalizedText pattern, which is the data-driven counterpart to this page.
Passing locale from controllers
Read the Accept-Language header once, validate it against your supported locales, and fall back to a default when the header is missing or unsupported. A getter on a shared base controller is a convenient place for this:
export default class AuthedController extends ApplicationController {
@BeforeAction()
public configureSerializers() {
this.serializerPassthrough({ locale: this.locale })
}
protected get locale() {
const locale = this.header('Accept-Language')
const locales = supportedLocales()
return locales.includes(locale as (typeof locales)[number]) ? locale : 'en-US'
}
}
this.header(...) reads a request header — it isn't a castParam concern, since castParam casts and validates route, query, and body params, not headers.
When querying models that use passthrough-conditioned associations (like currentLocalizedText), pass the same resolved locale on the query, in addition to serializerPassthrough:
const places = await Place
.passthrough({ locale: this.locale })
.preloadFor('forGuests')
.all()
serializerPassthrough and .passthrough(...) are two separate channels that happen to want the same value here — the former feeds serializer callbacks like customAttribute, the latter satisfies and: { locale: DreamConst.passthrough } conditions on associations. See passthrough for the association side of this pattern, including how to pair it with a fixed-locale fallback association.
Output encoding
I18nProvider interpolation does not HTML-escape values. Psychic is a JSON API, so encode output in the frontend context that renders it. See output encoding.