Skip to content

Intl

@voltage/intl wraps @formatjs/intl for NestJS. It provides a global Intl service for formatting messages, dates, numbers, and more. Locale is extracted from the URL path on every request and stored in async context, so the correct locale is always available without threading it through your call stack.

Terminal window
yarn add @voltage/intl @voltage/event-manager @voltage/logger @voltage/zod zod

@formatjs/intl is bundled and re-exported — you do not need to install it separately.

Call forRoot() once in your root module:

import { IntlModule } from '@voltage/intl';
@Module({
imports: [
IntlModule.forRoot({
locales: ['en', 'de'],
defaultLocale: 'en',
negotiate: true,
paths: ['/app/messages/compiled/[locale].json'],
}),
],
})
export class AppModule {}

The module is global — you do not need to import it in feature modules.

Inject Intl into any service or controller:

import { Intl } from '@voltage/intl';
@Injectable()
export class PasscodeMailFactory {
constructor(private readonly intl: Intl) {}
create(user: User, code: string): Mail {
return {
subject: this.intl.formatMessage(messages.subject),
body: this.intl.formatMessage(messages.body, { name: user.firstName, code }),
};
}
}

Use defineMessage() for a single descriptor or defineMessages() for a group. Keep descriptors colocated with the code that uses them:

import { defineMessages } from '@voltage/intl';
export const authMessages = defineMessages({
title: {
id: 'auth.title',
defaultMessage: 'Sign in',
description: 'Page title',
},
body: {
id: 'auth.body',
defaultMessage: 'Hi {name}, your code is {code}.',
},
});

defineMessages() accepts a type parameter, useful for keying messages off an enum:

export const salutationMessages = defineMessages<Salutation>({
[Salutation.Male]: { id: 'salutation.male', defaultMessage: 'Mr' },
[Salutation.Female]: { id: 'salutation.female', defaultMessage: 'Mrs' },
});
this.intl.formatMessage(salutationMessages[user.salutation]);

Translations are JSON files with message IDs as keys. The paths option accepts glob patterns; use [locale] as a placeholder for the locale code:

/app/messages/compiled/[locale].json

A compiled translation file:

{
"auth.title": "Anmelden",
"auth.body": "Hallo {name}, dein Code ist {code}."
}

The defaultMessage in each descriptor is the fallback when no translation exists for the current locale. Translation files are loaded at application bootstrap.

@formatjs/cli is included with the package. Use it to extract descriptors from source files and compile them into the JSON format above:

Terminal window
# Extract message descriptors from source
yarn formatjs extract 'src/**/*.{ts,tsx}' --out-file messages/en.json
# Compile to the format expected by the loader
yarn formatjs compile messages/de.json --out-file messages/compiled/de.json

Intl exposes the full @formatjs formatting API:

// Messages with optional interpolation values
this.intl.formatMessage(messages.greeting, { name: 'Ada' });
// Dates and times
this.intl.formatDate(order.createdAt, { dateStyle: 'medium' });
this.intl.formatTime(order.createdAt, { timeStyle: 'short' });
this.intl.formatDateTimeRange(start, end, { dateStyle: 'short' });
// Numbers
this.intl.formatNumber(price, { style: 'currency', currency: 'EUR' });
// Relative time
this.intl.formatRelativeTime(-2, 'day'); // "2 days ago"
// Plural category
this.intl.formatPlural(count); // 'zero' | 'one' | 'two' | 'few' | 'many' | 'other'

For JSX templates, the package ships formatting components under a separate import path:

import {
FormattedMessage,
FormattedDate,
FormattedTime,
FormattedNumber,
FormattedRelativeTime,
FormattedPlural,
} from '@voltage/intl/components';

These are injectable components — inject Intl via the service API in render methods instead when you need access to other injected dependencies. The components are convenient for simple inline formatting that doesn’t require service access:

<p>
<FormattedDate value={order.createdAt} dateStyle="medium" />
</p>

intl.locale returns the locale for the current request. The middleware sets it by extracting the leading path segment — /de/accountde. If no locale is in the path and negotiate is enabled, the Accept-Language header is used as a fallback. GET and HEAD requests without a recognised locale prefix are redirected to the appropriate locale URL automatically.

To format in a specific locale regardless of the current request, use intl.for(). The typical case is sending an email or notification in the recipient’s own locale, which may differ from the locale of the current request:

@Injectable()
export class WelcomeMailFactory {
constructor(private readonly intl: Intl) {}
create(user: User): Mail {
const intl = this.intl.for(user.locale);
return {
subject: intl.formatMessage(messages.subject),
body: intl.formatMessage(messages.body, { name: user.firstName }),
};
}
}

intl.for() returns the same Intl API scoped to that locale. intl.locales returns the full list of configured locales, which is useful when you need to generate content for all locales at once — for example in a sitemap or static page generator.

On bootstrap, Intl emits IntlCreateEvent once per locale with an empty messages object. The built-in handler populates it from the JSON files on disk. Any @OnEvent(IntlCreateEvent) handler in your application can also write into event.messages — useful for loading translations from a database, CMS, or other source:

import { OnEvent } from '@voltage/event-manager';
import { IntlCreateEvent } from '@voltage/intl';
@Injectable()
export class CmsTranslationLoader {
constructor(private readonly cms: CmsService) {}
@OnEvent(IntlCreateEvent)
protected async onIntlCreate(event: IntlCreateEvent) {
const translations = await this.cms.getTranslations(event.locale);
Object.assign(event.messages, translations);
}
}

All handlers run sequentially and their contributions are merged. Handlers registered after the built-in file loader can override individual messages.

Call intl.reload() to re-read all translation files without restarting the application:

await this.intl.reload();
interface IntlConfiguration {
/** Supported locale codes (ISO 639-1, e.g. 'en', 'de') */
locales: string[];
/** Locale to use when none can be determined from the request */
defaultLocale: string;
/** If true, fall back to the Accept-Language header when no locale is in the URL */
negotiate?: boolean;
/** Glob patterns for translation files. Use [locale] as a placeholder */
paths: string[];
}