Localization (i18n)
Translate each notification per recipient. Resolve a locale off the notifiable, translate with @nestjs/i18n, and render channel payloads in the recipient's language with localization.t().
Notifications go to people who don't all speak the same language. Localization resolves a locale per recipient and gives each channel payload method a translator, so one notification renders as English for one user and Portuguese for another — the call site never changes.
Translation itself — catalogs, plurals, fallbacks, number and date formatting — is
@nestjs/i18n's. Aviary adds the per-recipient locale and
an adapter that renders every delivery in it.
Use @nestjs/i18n
Configure I18nModule as the NestJS docs describe, then bind its translator with i18nTranslator()
from the /i18n subpath of core (@nestjs/i18n is an optional peer):
import { I18nModule, JsonI18nLoader } from '@nestjs/i18n';
import { NotificationsModule } from '@dudousxd/nestjs-notifications-core';
import { i18nTranslator } from '@dudousxd/nestjs-notifications-core/i18n';
@Module({
imports: [
I18nModule.forRoot({ defaultLocale: 'en', loader: new JsonI18nLoader({ path: 'i18n' }) }),
NotificationsModule.forRoot({
providers: [...i18nTranslator()],
localization: { defaultLocale: 'en' },
}),
],
})
export class AppModule {}I18nServiceTranslator turns localization.t(key, params) into
i18n.translate(key, { locale, args: params }), and runs each channel delivery inside
I18nContext.run(locale), so code that reads the ambient locale during the delivery
(@nestjs/i18n's t()) renders in the recipient's language too. A
@nestjs/mail mail class gets the locale explicitly instead: the
mail channel passes the recipient's locale to mailer.send(), and the class reads it from its
render context ({ locale }).
LocalizationOptions is { defaultLocale?, resolver?, translator? } — all optional. Without a
translator, t(key) returns the key.
Translate in a payload method
Every to<Channel>() method receives a context object carrying the resolved localization
(alongside the notifiable and tenant). Destructure what you need and call localization.t(key, params) — it resolves the key in the recipient's locale:
@Notification()
export class InvoicePaid implements MailNotification {
constructor(private invoiceId: string) {}
@Mail()
toMail({ localization }: ChannelContext): MailMessage {
return new MailMessage()
.subject(localization.t('invoice.paid.subject', { id: this.invoiceId }))
.line(localization.t('invoice.paid.body'));
}
}Send to a pt-BR recipient and the subject is "Fatura 42 paga"; to an en recipient, "Invoice 42
paid". Localization is { locale: string; t(key, params?): string }, and params is a
Record<string, unknown> (@nestjs/i18n's args).
Where the locale comes from
By default PropertyLocaleResolver reads the first present of locale, preferredLocale, lang, or
language off the notifiable, falling back to defaultLocale:
class User implements Notifiable {
constructor(public id: string, public locale = 'en') {} // 'pt-BR', 'es', …
}Need the locale from somewhere else — a DB row, a request header, a preferences table? Bind a custom
LocaleResolver:
interface LocaleResolver {
resolve(notifiable: Notifiable): string | undefined | Promise<string | undefined>;
}
NotificationsModule.forRoot({
localization: {
resolver: { resolve: async (u) => await prefs.localeFor(u.id) },
},
});Bring your own translator
Any other library works through the Translator interface (pass it as localization.translator, or
bind it under NOTIFICATION_TRANSLATOR). runInLocale is optional: implement it when the library
has an ambient locale the delivery should run in.
interface Translator {
translate(key: string, locale: string, params?: TranslateParams): string;
runInLocale?<R>(locale: string, fn: () => R): R;
}Localization is resolved once per delivery, per recipient, so a single send() to many
notifiables renders each in its own language. The resolver and translator can also be supplied via
DI under NOTIFICATION_LOCALE_RESOLVER / NOTIFICATION_TRANSLATOR if you'd rather wire them as
providers than inline options.
The moving parts
Everything above is driven by a few concrete exports you can also use directly — in a test, a custom resolver, or your own rendering code.
makeLocalization(translator, locale) binds a translator to a resolved locale, producing the
Localization object ({ locale, t }) your payload methods receive — the shorthand t(key, params)
is just translator.translate(key, locale, params). Reach for it when you render a message outside a
channel (a preview, a digest email) and want the same .t() ergonomics:
import { makeLocalization } from '@dudousxd/nestjs-notifications-core';
const l10n = makeLocalization(translator, 'pt-BR');
l10n.t('hi', { name: 'Ana' }); // translator.translate('hi', 'pt-BR', { name: 'Ana' })LocalizationService is the injectable that the ChannelRunner
uses to resolve a Localization per delivery — forNotifiable(notifiable) runs the bound
LocaleResolver (default: PropertyLocaleResolver) and wraps the resolved locale with the bound
Translator via makeLocalization; run(locale, fn) runs fn in the translator's ambient locale. It's provided by NotificationsModule, so you can inject it if
you need the same resolution the channels get:
@Injectable()
export class DigestBuilder {
constructor(private readonly l10n: LocalizationService) {}
async subjectFor(user: User) {
const localization = await this.l10n.forNotifiable(user);
return localization.t('digest.subject', { count: 3 });
}
}Fallback chains
Deliver a notification down an ordered chain of channels — push first, escalate to SMS, then email — stopping at the first that reaches the recipient. Opt in per notification with fallback().
Channels
A channel is a transport — mail, database, Slack, and the real-time SSE / WebSocket channels. Import a channel's module and it registers itself; the notification's to<Channel>() method shapes the payload.