Internationalization

The @adonisjs/i18n official package adds support for internationalization and localization to your AdonisJS applications.

  • The internationalization helpers allow you to perform language-sensitive formatting of specific values such as date, currency, and name.
  • The localization layer allows you to store translations and reference them within the Edge templates, validation errors, auth exceptions, and so on.

The I18n (shorthand for Internationalization) package must be installed and configured separately.

npm i @adonisjs/i18n
node ace configure @adonisjs/i18n
# CREATE: app/Middleware/DetectUserLocale.ts
# CREATE: ./resources/lang
# CREATE: config/i18n.ts
# UPDATE: .adonisrc.json { providers += "@adonisjs/i18n" }
  • Helpers to perform language-sensitive formatting for dates, currencies, names, and so on.
  • Support for storing translations in ICU messages format .
  • Add your custom messages formatter and translations loader.

Usage

Following is a basic example of importing the installed package and formatting values.

The I18n.locale method returns an instance of I18n class for a specific locale. The locale code must be a valid ISO 639-1 standard language code.

import I18n from '@ioc:Adonis/Addons/I18n'
I18n.locale('en-US').formatDate(new Date())
// 10/8/2021
I18n.locale('fr').formatCurrency(100, { currency: 'EUR' })
// 100,00 €
const luxonDate = DateTime.local().minus({ minutes: 10 })
I18n.locale('pt').formatRelativeTime(luxonDate, 'auto')
// há 10 minutos

You can make use of the formatMessage method to format stored translations. The method accepts the message key as the first argument and the data as the second argument.

import I18n from '@ioc:Adonis/Addons/I18n'
I18n
.locale('en-US')
.formatMessage('messages.greeting', { name: 'Virk' })

Learn more about formatting translations →


Usage during HTTP requests

It is recommended to use the ctx.i18n object during the HTTP requests. It is an isolated instance of I18n class for the current request.

Route.get('/', async ({ i18n }) => {
return i18n.formatCurrency(100, { currency: 'EUR' })
})

By default, the locale of ctx.i18n is set to the application default locale. Therefore, it is recommended to use the DetectUserLocale middleware to find the user locale and update it for the rest of the request.

Config

The configuration is stored inside the config/i18n.ts file. You can always find the up-to-date config stub on GitHub .

import Application from '@ioc:Adonis/Core/Application'
import { I18nConfig } from '@ioc:Adonis/Addons/I18n'
const i18nConfig: I18nConfig = {
translationsFormat: 'icu',
defaultLocale: 'en',
// Optional
supportedLocales: [],
fallbackLocales: {},
provideValidatorMessages: true,
loaders: {
fs: {
enabled: true,
location: Application.resourcesPath('lang'),
},
},
}
export default i18nConfig

translationsFormat

The format to be used for formatting translations. Officially only the ICU messages format is supported.


defaultLocale

The defaultLocale is the default language of your application. It is always static and cannot be changed at runtime. We look up translations from the default locale when the current user language is not supported, and also, there is no fallback available.

const i18nConfig: I18nConfig = {
defaultLocale: 'en'
}

supportedLocales

It is an array of ISO 639-1 formatted language codes that your application supports. If the user language is not mentioned inside this array, we will use the defaultLocale to look up translations.

You can optionally define the supportedLocales inside the config file. Otherwise, we will infer the supported locales from the language directories you have created inside the resources/lang directory.

const i18nConfig: I18nConfig = {
supportedLocales: ['fr', 'en', 'it']
}

fallbackLocales

The fallbackLocales is a key-value pair of the locales that your application supports along with their fallback locales.

For example: Using Spanish as a fallback for the Catalan language makes more sense than using English. Therefore, you can define the fallback locales yourself.

The locale for which you have defined the fallback should be part of the supportedLocales array.

const i18nConfig: I18nConfig = {
fallbackLocales: {
ca: 'es'
}
}

provideValidatorMessages

Enable/disable the support for providing validator messages through translation files. The messages are provided when the flag is set to true.

Learn more about translating validation messages .


loaders

The loaders are used to load messages from some sort of storage. Officially we ship with an implementation of fs loader that loads .json or .yaml files from the filesystem.

Locale matching

We allow you to define translations for a specific region or use a two-digit language code for generic support.

For example: If you store the translations for the French language inside the fr directory, then all variations of the french language will see the same messages.

However, if you create region-specific directories such as fr-ca or fr-ch, the translations from the best matching locale will be served.

The style of locale matching is known as content negotiation. Instead of looking for the exact match, we negotiate for the closest match.

Finding the best matching locale

You should use the I18n.getSupportedLocale method to find the best locale for the user language.

The method accepts a string or an array of user languages and returns the matching locale supported by your application. null is returned when no match is found.

import I18n from '@ioc:Adonis/Addons/I18n'
const userLanguage = 'en-US'
const bestMatch = I18n.getSupportedLocale(userLanguage)
if (bestMatch) {
I18n.locale(bestMatch).formatMessage()
} else {
I18n.locale(I18n.defaultLocale).formatMessage()
}

Detecting user locale

You should use the DetectUserLocale middleware stored inside the app/Middleware directory to find the locale for the incoming HTTP request.

By default, the middleware uses the Accept-language HTTP header to find the language of the user's browser.

However, you can change the implementation of this middleware and use any strategy that fits your use case and application needs. Just keep the following points in mind.

  • Make sure always to pass the user-selected locale to the I18n.getSupportedLocale(userLocale) method to find the best possible locale supported by your application.
  • If a match is found, call the ctx.i18n.switchLocale(locale) method to switch the locale for the rest of the request.

Also, make sure to register the middleware inside the start/kernel.ts file.

start/kernel.ts
Server.middleware.register([
// ... other middleware(s)
() => import('App/Middleware/DetectUserLocale')
])

Check out this example project that uses the in-application language switcher and sessions for managing the user preferred language.

Translations storage

The fs (default) loader looks for the translations inside the resources/lang directory. You must create a sub-directory for every locale that your application supports. For example:

The language directory must be named after a valid ISO 639-1 language code

resources/lang
├── en
└── fr

The loader will read all the .json and .yaml files. Also, feel free to create multiple sub-directories or files inside a language directory.

resources/lang
├── en
│   ├── emails.yaml
│   └── validator.json
└── fr
└── validator.json
resources/lang/fr/validator.json
{
"shared": {
"required": "Ce champ est requis"
}
}
resources/lang/en/emails.yaml
welcome:
content: >-
<h2> Welcome to AdonisJS </h2>
<p> Click <a href="{ url }"> here </a> to verify your account </p>

Formatting translations

The icu formatter lets you write translations using the ICU messages format . It is an industry-standard format for writing translations and is supported by many translation services like Crowdin and Lokalise.

Given the following message inside the en/messages.json file.

resources/lang/en/messages.json
{
"title": "A fully featured web framework for Node.js."
}

You can render it as follows.

import I18n from '@ioc:Adonis/Addons/I18n'
I18n.locale('en').formatMessage('messages.title')

And render it inside templates using the t helper method.

<h1> {{ t('messages.title') }} </h1>

Interpolation

The ICU messages syntax uses a single curly brace for referencing dynamic values. For example:

The ICU messages syntax does not support nested data sets and hence you can only access properties from a flat object during interpolation.

{
"greeting": "Hello { username }"
}
{{ t('messages.greeting', { username: 'Virk' }) }}

You can also write HTML within the messages. However, do make sure to use three curly braces within the Edge templates to render HTML without escaping it.

Number format

You can format numeric values within the translation messages using the {key, type, format} syntax. In the following example:

  • The amount is the runtime value.
  • The number is the formatting type.
  • And the ::currency/USD is the currency format with a number skeleton
{
"bagel_price": "The price of this bagel is {amount, number, ::currency/USD}"
}
{{ t('bagel_price', { amount: 2.49 }) }}
The price of this bagel is $2.49

Following are some examples using the number format with different formatting styles and number skeletons.

Length of the pole: {price, number, ::measure-unit/length-meter}
Account balance: {price, number, ::currency/USD compact-long}

Date/time format

You can format the Date instances or the luxon DateTime instances using the {key, type, format} syntax. In the following example:

  • The expectedDate is the runtime value.
  • The date is the formatting type.
  • And the medium is the date format.
{
"shipment_update": "Your package will arrive on {expectedDate, date, medium}"
}
{{ t('shipment_update', { expectedDate: luxonDateTime }) }}
Your package will arrive on Oct 16, 2021

Similarly, you can use the time format to format time for the current locale.

{
"appointment": "You have an appointment today at {appointmentAt, time, ::h:m a}"
}
You have an appointment today at 2:48 PM

Available date/time skeletons

ICU provides a wide array of patterns to customize date-time format. However, not all of them are available via ECMA402's Intl API. Therefore, we only support the following patterns.

SymbolDescription
GEra designator
yyear
Mmonth in year
Lstand-alone month in year
dday in month
Eday of week
elocal day of week e..eee is not supported
cstand-alone local day of week c..ccc is not supported
aAM/PM marker
hHour [1-12]
HHour [0-23]
KHour [0-11]
kHour [1-24]
mMinute
sSecond
zTime Zone

Plural rules

ICU message syntax has first-class support for defining the plural rules within your messages. For example:

In the following example, we use YAML over JSON since it is easier to write multiline text in YAML.

cart_summary:
"You have {itemsCount, plural,
=0 {no items}
one {1 item}
other {# items}
} in your cart"
{{ t('messages.cart_summary', { itemsCount: 1 }) }}
You have 1 item in your cart.

The # is a special token to be used as a placeholder for the numeric value. It will be formatted as {key, number}.

{{ t('messages.cart_summary', { itemsCount: 1000 }) }}
<!-- Output -->
<!-- You have 1,000 items in your cart -->

Available plural categories

The plural rule uses the {key, plural, matches} syntax. The matches is a literal value and is matched to one of the following plural categories.

CategoryDescription
zeroThis category is used for languages with grammar specialized specifically for zero number of items. (Examples are Arabic and Latvian)
oneThis category is used for languages with grammar explicitly specialized for one item. Many languages, but not all, use this plural category. (Many popular Asian languages, such as Chinese and Japanese, do not use this category.)
twoThis category is used for languages that have grammar explicitly specialized for two items. (Examples are Arabic and Welsh.)
fewThis category is used for languages with grammar explicitly specialized for a small number of items. For some languages, this is used for 2-4 items, for some 3-10 items, and other languages have even more complex rules.
manyThis category is used for languages that have a specialized grammar for a more significant number of items. (Examples are Arabic, Polish, and Russian.)
otherThis category is used if the value doesn't match one of the other plural categories. Note that this is used for "plural" for languages (such as English) that have a simple "singular" versus "plural" dichotomy.
=valueThis is used to match a specific value regardless of the plural categories of the current locale.
The table's content is referenced from formatjs.io

Select

The select format allows you to choose the output by matching a value against one of the many choices. Writing gender-specific text is an excellent example of the select format.

Yaml
auto_reply:
"{gender, select,
male {He}
female {She}
other {They}
} will respond shortly."
{{ t('messages.auto_reply', { gender: 'female' }) }}
She will respond shortly.

Select ordinal

The select ordinal format allows you to choose the output based upon the ordinal pluralization rules. The format is similar to the plural format. However, the value is mapped to an ordinal plural category.

anniversary_greeting:
"It's my {years, selectordinal,
one {#st}
two {#nd}
few {#rd}
other {#th}
} anniversary"
{{ t('messages.anniversary_greeting', { years: 2 }) }}
It's my 2nd anniversary

Available select ordinal categories

The select ordinal format uses the {key, selectordinal, matches} syntax. The match is a literal value and is matched to one of the following plural categories.

CategoryDescription
zeroThis category is used for languages with grammar specialized specifically for zero number of items. (Examples are Arabic and Latvian.)
oneThis category is used for languages with grammar explicitly specialized for one item. Many languages, but not all, use this plural category. (Many popular Asian languages, such as Chinese and Japanese, do not use this category.)
twoThis category is used for languages that have grammar explicitly specialized for two items. (Examples are Arabic and Welsh.)
fewThis category is used for languages with grammar explicitly specialized for a small number of items. For some languages, this is used for 2-4 items, for some 3-10 items, and other languages have even more complex rules.
manyThis category is used for languages with specialized grammar for a larger number of items. (Examples are Arabic, Polish, and Russian.)
otherThis category is used if the value doesn't match one of the other plural categories. Note that this is used for "plural" for languages (such as English) that have a simple "singular" versus "plural" dichotomy.
=valueThis is used to match a specific value regardless of the plural categories of the current locale.
The table's content is referenced from formatjs.io

Intl formatters

The Intl formatters are thin wrappers over the Node.js Intl API . Creating a new instance of the Intl classes is slow, so we memoize the constructors to speed up things. See benchmarks

formatNumber

The formatNumber uses the Intl.NumberFormat class to format a numeric value.

  • The first argument is the value to format. It must be a number, bigint, or a string representation of a number.

  • The second argument is the options. They are the same as the options accepted by the Intl.NumberFormat class.

import I18n from '@ioc:Adonis/Addons/I18n'
I18n
.locale('en')
.formatNumber(123456.789, {
maximumSignificantDigits: 3
})

formatCurrency

The formatCurrency method uses the Intl.NumberFormat class but implicitly sets the style to currency.

import I18n from '@ioc:Adonis/Addons/I18n'
I18n
.locale('en')
.formatCurrency(200, {
currency: 'USD'
})

formatDate

The formatDate method uses the Intl.DateTimeFormat class to format a date.

  • The first argument is the date to format. It can be an ISO date string, a timestamp, an instance of the JavaScript Date class, or a luxon DateTime.

  • The second argument is the options. They are the same as the options accepted by the Intl.DateTimeFormat class.

import I18n from '@ioc:Adonis/Addons/I18n'
I18n
.locale('en')
.formatDate(new Date(), {
dateStyle: 'long'
})

formatTime

The formatTime method uses the Intl.DateTimeFormat class, but implicitly sets the timeStyle to medium.

import I18n from '@ioc:Adonis/Addons/I18n'
I18n
.locale('en')
.formatTime(new Date(), {
timeStyle: 'long'
})

formatRelativeTime

The formatRelativeTime method using the Intl.RelativeTimeFormat class to format a value to a relative time representation string.

  • The first argument is the value of the relative time. It can be an ISO date string, an absolute numeric diff, an instance of the JavaScript Date class, or an instance of luxon DateTime.

  • The second argument is the formatting unit. Along with the officially supported units , we also support an additional auto unit.

  • The third argument is the options. They are the same as the options accepted by the Intl.RelativeTimeFormat class.

import { DateTime } from 'luxon'
import I18n from '@ioc:Adonis/Addons/I18n'
const luxonDate = DateTime.local().plus({ hours: 2 })
I18n
.locale('en')
.formatRelativeTime(luxonDate, 'hours')

We will find the best unit when using the formatting unit is set to auto. For example:

const luxonDate = DateTime.local().plus({ hours: 2 })
I18n
.locale('en')
.formatRelativeTime(luxonDate, 'auto')
// In 2 hours 👈
const luxonDate = DateTime.local().plus({ hours: 200 })
I18n
.locale('en')
.formatRelativeTime(luxonDate, 'auto')
// In 8 days 👈

formatPlural

The formatPlural method uses the Intl.PluralRules and returns a plural category for a given numeric value.

  • The first argument is the value. It must be a number or a string representation of a number.

  • The second argument is the options. They are the same as the options accepted by the Intl.PluralRules class.

import I18n from '@ioc:Adonis/Addons/I18n'
I18n.locale('en').formatPlural(0)
// other
I18n.locale('en').formatPlural(1)
// one
I18n.locale('en').formatPlural(2)
// other

Validator messages

Following are steps to configure the i18n package to provide the validation messages from the translations files.

  1. Set the value of provideValidatorMessages = true inside the config file.
  2. Create a validator.json file inside every language directory.
  3. Define messages for the validation rules inside the shared object.
resources/lang/en/validator.json
{
"shared": {
"required": "The value for the field is required",
"unique": "Email is already in use",
"minLength": "The field must have { minLength } items"
}
}

The messages from the shared key are automatically provided to the validator. You can also be specific and define a message for a field + rule combination. For example:

{
"shared": {
"required": "The value for the field is required",
"username.required": "Username is required to create an account"
}
}

Custom messages bag

If some part of your application needs specific validation messages, you can define them within the validator.json file under a different top-level key and then reference them using the i18n.validatorMessages() method.

resources/lang/en/validator.json
{
"shared": {},
"contact": {
"email.required": "Enter the email so that we can contact you",
"message.required": "Describe your project in a few words."
}
}

Now, you can reference the messages from the contact object on the validator as follows.

import { schema } from '@ioc:Adonis/Core/Validator'
import { HttpContextContract } from '@ioc:Adonis/Core/HttpContext'
export default class ContactValidator {
constructor(protected ctx: HttpContextContract) {}
public schema = schema.create({})
public messages = this.ctx.i18n.validatorMessages('validator.contact')
}

Auth messages

You can also provide translations for the exceptions raised by the auth package. The translations must be defined inside the auth.json file using the exception code as the translation key.

The translations are used for the response text and not the error.message property. They will still be in English and hardcoded.

{
"E_INVALID_AUTH_SESSION": "Your session has expired",
"E_INVALID_API_TOKEN": "Invalid or expired API token",
"E_INVALID_BASIC_CREDENTIALS": "Invalid credentials",
"E_INVALID_AUTH_UID": "Invalid credentials",
"E_INVALID_AUTH_PASSWORD": "Invalid credentials"
}

Translating emails

Since emails are usually sent in the background (outside of the HTTP request lifecycle), you must explicitly pass the i18n instance to the email templates.

The t helper method is an alias for the i18n.formatMessage, it will format messages in the same language for which you created the i18n class instance and passed it to the template state.

import Mail from '@ioc:Adonis/Addons/Mail'
import I18n from '@ioc:Adonis/Addons/I18n'
const i18n = I18n.locale(customerLocale)
await Mail.send((message) => {
message
.subject(i18n.formatMessage('emails.welcome_subject'))
.htmlView('emails/welcome', { i18n })
})

Reloading translations

The translations are loaded and cached within the memory on the application start. Therefore, any changes you make to the translation files are not reflected until you restart the process.

During development, the dev server will restart itself on file change. However, in production, you will have to restart the server manually (like a new deployment).

If for some reason, you want to reload the translations within the running process, then you can make use of the I18n.reloadTranslations() method to do it.

import I18n from '@ioc:Adonis/Addons/I18n'
await I18n.reloadTranslations()

Reporting missing translations

To help you progressively add translations for new languages, we report the missing translations by emitting the i18n:missing:translation event.

Create a new preload file start/i18n.ts by running the following Ace command. Select all the environments.

node ace make:prldfile i18n

Open the newly created file and paste the following contents inside it. Currently, we are using the I18n.prettyPrint method to log the message to the console. However, you can also use the logger to log the message.

start/i18n.ts
import Event from '@ioc:Adonis/Core/Event'
import I18n from '@ioc:Adonis/Addons/I18n'
Event.on('i18n:missing:translation', I18n.prettyPrint)

Add custom message formatter

The message formatter defines the syntax and the capabilities of the stored translations. The package ships with an icu formatter that uses the ICU messages syntax for writing translations.

However, you can also register a custom message formatter using the I18n.extend method. The formatter implementation must adhere to the TranslationsFormatterContract interface.

interface TranslationsFormatterContract {
readonly name: string
format(message: string, locale: string, data?: Record<string, any>): string
}

name

A unique name for the formatter. It will be a static string value.


format

The format method receives the following arguments and must return a formatted string.

  • The first argument is the message text.
  • The second argument is the locale for which the formatting should happen.
  • Finally, the data object for dynamic values.

Dummy implementation

Following is a very straightforward implementation that uses the Edge template engine for formatting translations.

Step 1. Create formatter class.

Create a new file MustacheFormatter.ts within the providers directory and paste the following contents inside it.

import type { ViewContract } from '@ioc:Adonis/Core/View'
import type { TranslationsFormatterContract } from '@ioc:Adonis/Addons/I18n'
export class MustacheFormatter implements TranslationsFormatterContract {
public readonly name = 'mustache'
constructor(private view: ViewContract) {}
public format(message: string, _: string, data?: Record<string, any>) {
return this.view.renderRawSync(message, data)
}
}

Step 2. Extend I18n and register the formatter

Open the providers/AppProvider.ts file and register the formatter within the boot method.

providers/AppProvider.ts
import { ApplicationContract } from '@ioc:Adonis/Core/Application'
import { MustacheFormatter } from './MustacheFormatter'
export default class AppProvider {
constructor(protected app: ApplicationContract) {}
public register() {
// Register your own bindings
}
public async boot() {
const I18n = this.app.container.resolveBinding('Adonis/Addons/I18n')
const View = this.app.container.resolveBinding('Adonis/Core/View')
I18n.extend('mustache', 'formatter', () => new MustacheFormatter(View))
}
public async ready() {
// App is ready
}
public async shutdown() {
// Cleanup, since app is going down
}
}

Step 3. Use the mustache formatter

Update the config file and set the translationsFormat to mustache.

{
translationsFormat: 'mustache'
}

Add custom messages loader

The message loader is responsible for loading the messages from a permanent source. The package ships with an fs formatter that reads the .json and .yaml files from the file system.

However, you can also register custom loaders using the I18n.extend method. The loader implementation must adhere to the LoaderContract interface.

type Translations = {
[lang: string]: Record<string, string>
}
interface LoaderContract {
load(): Promise<Translations>
}

Loaders only need to implement a single method called load that returns all the translations as an object.

The top-level keys of the object are the language codes, and the value is another object of messages.

{
en: {},
fr: {},
it: {}
}

Also, make sure to convert nested messages inside a language object to a flat object. For example:

{
en: {
'messages.title': '',
'messages.subtitle': ''
}
}

Dummy implementation

Following is a very straightforward implementation that reads the messages from the Database using Lucid.

Step 1. Create the loader class.

Create a new file DbLoader.ts within the providers directory and paste the following contents inside it.

import type { DatabaseContract } from '@ioc:Adonis/Lucid/Database'
import type {
Translations,
LoaderContract
} from '@ioc:Adonis/Addons/I18n'
export type DbLoaderConfig = {
enabled: boolean
table: string
}
export class DbLoader implements LoaderContract {
constructor(private db: DatabaseContract, private config: DbLoaderConfig) {}
public async load() {
const rows = await this.db.from(this.config.table)
return rows.reduce<Translations>((result, row) => {
result[row.locale] = result[row.locale] || {}
result[row.locale][row.key] = row.message
return result
}, {})
}
}

Step 2. Extend I18n and register the loader

Open the providers/AppProvider.ts file and register the loader within the boot method.

providers/AppProvider.ts
import { ApplicationContract } from '@ioc:Adonis/Core/Application'
import { DbLoader } from './DbLoader'
export default class AppProvider {
constructor(protected app: ApplicationContract) {}
public register() {
// Register your own bindings
}
public async boot() {
const I18n = this.app.container.resolveBinding('Adonis/Addons/I18n')
const Db = this.app.container.resolveBinding('Adonis/Lucid/Database')
I18n.extend('db', 'loader', (_, config) => {
return new DbLoader(Db, config)
})
}
public async ready() {
// App is ready
}
public async shutdown() {
// Cleanup, since app is going down
}
}

Step 3. Use the db loader

Update the config file and add the db loader key to the loaders object.

{
loaders: {
fs: {},
db: {
enabled: true,
table: 'translations'
}
}
}

Step 4. Create translations table

Use the following migration to create the translations table.

When running the migration, you will have to disable the db loader inside the config file. Otherwise, the loader will attempt to read the messages from a non-existing table.

import BaseSchema from '@ioc:Adonis/Lucid/Schema'
export default class Translations extends BaseSchema {
protected tableName = 'translations'
public async up() {
this.schema.createTable(this.tableName, (table) => {
table.increments('id')
table.string('locale', 8).notNullable()
table.string('key').notNullable()
table.text('message', 'longtext').notNullable()
table.timestamp('created_at', { useTz: true })
table.timestamp('updated_at', { useTz: true })
table.unique(['locale', 'key'])
})
}
public async down() {
this.schema.dropTable(this.tableName)
}
}

Additional reading

Make sure to read the API reference guide to view all the available properties and methods.