Internationalization and Localization

This guide covers internationalization (i18n) and localization in AdonisJS applications. You will learn how to:

  • Configure the i18n package and set up supported locales
  • Store and organize translation files for multiple languages
  • Resolve and format translations in controllers and Edge templates
  • Translate validation error messages automatically
  • Use ICU message format for dynamic content (plurals, dates, numbers, gender)
  • Format values like currencies, dates, and relative times
  • Create custom translation loaders and formatters

Overview

When building applications for a global audience, you need two capabilities: localization (translating text into multiple languages) and internationalization (formatting values like dates, numbers, and currencies according to regional conventions). The @adonisjs/i18n package provides both.

Localization involves writing translations for each language your application supports and referencing them in Edge templates, validation messages, or directly via the i18n API. Instead of hardcoding strings like "Welcome back!" throughout your codebase, you store translations in dedicated files and look them up by key. This makes it straightforward to add new languages without modifying your application code.

Internationalization handles the formatting side. The same date might display as "January 10, 2026" in the US but "10 janvier 2026" in France. The i18n package uses the browser-standard Intl API under the hood, giving you locale-aware formatting for numbers, currencies, dates, times, and more.

The package integrates with AdonisJS through middleware that detects the user's preferred language from the Accept-Language header, creates a locale-specific i18n instance, and makes it available throughout the request lifecycle via HTTP Context.

Installation

Install and configure the package using the following command:

node ace add @adonisjs/i18n
See steps performed by the add command
  1. Installs the @adonisjs/i18n package using the detected package manager.

  2. Registers the following service provider inside the adonisrc.ts file.

    adonisrc.ts
    {
      providers: [
        // ...other providers
        () => import('@adonisjs/i18n/i18n_provider')
      ]
    }
  3. Creates the config/i18n.ts file.

  4. Creates detect_user_locale_middleware inside the middleware directory.

  5. Registers the following middleware inside the start/kernel.ts file.

    start/kernel.ts
    router.use([
      () => import('#middleware/detect_user_locale_middleware')
    ])

Configuration

The configuration for the i18n package is stored within the config/i18n.ts file.

config/i18n.ts
import app from '@adonisjs/core/services/app'
import { defineConfig, formatters, loaders } from '@adonisjs/i18n'

const i18nConfig = defineConfig({
  defaultLocale: 'en',
  formatter: formatters.icu(),

  loaders: [
    loaders.fs({
      location: app.languageFilesPath()
    })
  ],
})

export default i18nConfig

See also: Config stub

Configuration options

OptionDescription
defaultLocaleThe fallback locale when your application does not support the user's language. Translations and value formatting fall back to this locale.
formatterThe message format for storing translations. AdonisJS uses the ICU message format by default, a widely accepted standard supported by translation services like Crowdin and Lokalise. You can also create custom formatters.
fallbackLocalesA key-value pair defining fallback relationships between locales. For example, you might show Spanish content to users who speak Catalan.
supportedLocalesAn array of locales your application supports. If omitted, this is inferred from your translation files.
loadersA collection of loaders for loading translations. The default filesystem loader reads from resources/lang. You can create custom loaders to load translations from a database or remote service.

Configuring fallback locales

When a translation is missing for a specific locale, the i18n package can fall back to a related language before using the default locale. This is useful for regional variants.

config/i18n.ts
export default defineConfig({
  formatter: formatters.icu(),
  defaultLocale: 'en',
  fallbackLocales: {
    'de-CH': 'de',  // Swiss German falls back to German
    'fr-CH': 'fr',  // Swiss French falls back to French
    ca: 'es'        // Catalan falls back to Spanish
  }
})

Configuring supported locales

By default, the package infers supported locales from your translation files. If you have translation directories for en, fr, and es, those become your supported locales automatically. To explicitly define supported locales, use the supportedLocales option.

config/i18n.ts
export default defineConfig({
  formatter: formatters.icu(),
  defaultLocale: 'en',
  supportedLocales: ['en', 'fr', 'it']
})

Storing translations

Translations are stored in the resources/lang directory. Create a subdirectory for each language using the IETF language tag format (like en, fr, de).

resources
├── lang
│   ├── en
│   └── fr

For regional variants, create subdirectories with the region code. AdonisJS automatically falls back from regional to base translations when a key is missing.

resources
├── lang
│   ├── en        # English (base)
│   ├── en-us     # English (United States)
│   └── en-gb     # English (United Kingdom)

See also: ISO language codes

Translation file format

Store translations in .json or .yaml files. You can create nested directories for better organization.

resources
├── lang
│   ├── en
│   │   └── messages.json
│   └── fr
│       └── messages.json

Translations use the ICU message syntax, which supports interpolation, pluralization, and formatting.

resources/lang/en/messages.json
{
  "greeting": "Hello world"
}
resources/lang/fr/messages.json
{
  "greeting": "Bonjour le monde"
}

Resolving translations

To look up and format translations, create a locale-specific instance of the I18n class using the i18nManager.locale method.

app/services/example_service.ts
import i18nManager from '@adonisjs/i18n/services/main'

/**
 * Create I18n instances for specific locales
 */
const en = i18nManager.locale('en')
const fr = i18nManager.locale('fr')

Use the .t method to format a translation by its key. The key follows the pattern filename.path.to.key.

app/services/example_service.ts
import i18nManager from '@adonisjs/i18n/services/main'

const i18n = i18nManager.locale('en')
i18n.t('messages.greeting') // "Hello world"
app/services/example_service.ts
import i18nManager from '@adonisjs/i18n/services/main'

const i18n = i18nManager.locale('fr')
i18n.t('messages.greeting') // "Bonjour le monde"

Understanding fallback behavior

Each I18n instance has a pre-configured fallback language based on your fallbackLocales configuration. When a translation is missing for the main language, the fallback is used.

config/i18n.ts
export default defineConfig({
  defaultLocale: 'en',
  fallbackLocales: {
    'de-CH': 'de',
    'fr-CH': 'fr'
  }
})
app/services/example_service.ts
import i18nManager from '@adonisjs/i18n/services/main'

const swissGerman = i18nManager.locale('de-CH')
swissGerman.fallbackLocale // "de" (from fallbackLocales)

const swissFrench = i18nManager.locale('fr-CH')
swissFrench.fallbackLocale // "fr" (from fallbackLocales)

const english = i18nManager.locale('en')
english.fallbackLocale // "en" (uses defaultLocale)

Handling missing translations

When a translation is missing in both the main and fallback locales, the .t method returns an error string.

app/services/example_service.ts
import i18nManager from '@adonisjs/i18n/services/main'

const i18n = i18nManager.locale('en')
i18n.t('messages.hero_title')
// "translation missing: en, messages.hero_title"

To replace this with a custom fallback value, pass it as the second parameter.

app/services/example_service.ts
import i18nManager from '@adonisjs/i18n/services/main'

const i18n = i18nManager.locale('en')
i18n.t('messages.hero_title', '')
// "" (empty string instead of error message)

For a global fallback strategy, define a fallback function in your config. This function receives the translation path and locale code, and must return a string.

config/i18n.ts
import { defineConfig } from '@adonisjs/i18n'

export default defineConfig({
  defaultLocale: 'en',
  formatter: formatters.icu(),
  fallback: (identifier, locale) => {
    return '' // Return empty string for all missing translations
  },
})

Detecting user locale during HTTP requests

The detect_user_locale_middleware created during installation handles locale detection automatically. It reads the Accept-Language header from incoming requests, creates an I18n instance for the detected locale, and shares it via HTTP Context.

With this middleware active, you can access translations in controllers and Edge templates.

app/controllers/posts_controller.ts
import type { HttpContext } from '@adonisjs/core/http'

export default class PostsController {
  async store({ i18n, session, response }: HttpContext) {
    // Create post...
    
    session.flash('success', {
      message: i18n.t('post.created')
    })
    
    return response.redirect().back()
  }
}

In Edge templates, use the t helper function.

resources/views/welcome.edge
<h1>{{ t('messages.heroTitle') }}</h1>

Customizing locale detection

The middleware is part of your application codebase, so you can modify the getRequestLocale method to use custom detection logic. For example, you might read the locale from a user's profile, a cookie, or a URL parameter instead of the Accept-Language header.

Translating validation messages

The detect_user_locale_middleware hooks into the Request validator to provide validation messages from your translation files.

app/middleware/detect_user_locale_middleware.ts
export default class DetectUserLocaleMiddleware {
  static {
    RequestValidator.messagesProvider = (ctx) => {
      return ctx.i18n.createMessagesProvider()
    }
  }
}

Store validation translations in a validator.json file under the shared key. Define messages for validation rules or specific field + rule combinations.

resources/lang/en/validator.json
{
  "shared": {
    "fields": {
      "first_name": "first name"
    },
    "messages": {
      "required": "Enter {field}",
      "username.required": "Choose a username for your account",
      "email": "The email must be valid"
    }
  }
}
resources/lang/fr/validator.json
{
  "shared": {
    "fields": {
      "first_name": "Prénom"
    },
    "messages": {
      "required": "Remplisser le champ {field}",
      "username.required": "Choissisez un nom d'utilisateur pour votre compte",
      "email": "L'email doit être valide"
    }
  }
}

Using translations with VineJS directly

During HTTP requests, the middleware automatically registers a custom messages provider for validation. When using VineJS outside of HTTP requests (in Ace commands or queue jobs), register the messages provider explicitly.

app/jobs/create_user_job.ts
import { createJobValidator } from '#validators/jobs'
import i18nManager from '@adonisjs/i18n/services/main'

/**
 * Get an i18n instance for the desired locale
 */
const i18n = i18nManager.locale('fr')

await createJobValidator.validate(data, {
  /**
   * Register the messages provider manually
   */
  messagesProvider: i18n.createMessagesProvider()
})

ICU message format

The ICU (International Components for Unicode) message format is the standard for storing translations that need dynamic content. It supports interpolation, number formatting, date formatting, pluralization, and gender-based selection.

Interpolation

Reference dynamic values using single curly braces.

resources/lang/en/messages.json
{
  "greeting": "Hello { username }"
}
resources/views/welcome.edge
{{ t('messages.greeting', { username: 'Virk' }) }}
{{-- Output: Hello Virk --}}
Tip

The ICU messages syntax does not support nested data sets. You can only access properties from a flat object during interpolation. If you need nested data, flatten it before passing to the translation function.

To include HTML in translations, use three curly braces in Edge templates to prevent escaping.

resources/lang/en/messages.json
{
  "greeting": "<p>Hello <strong>{ username }</strong></p>"
}
resources/views/welcome.edge
{{{ t('messages.greeting', { username: 'Virk' }) }}}

Number formatting

Format numeric values using the {key, number, format} syntax. The format can include number skeletons for precise control.

resources/lang/en/messages.json
{
  "bagel_price": "The price of this bagel is {amount, number, ::currency/USD}"
}
resources/views/menu.edge
{{ t('bagel_price', { amount: 2.49 }) }}
{{-- Output: The price of this bagel is $2.49 --}}

More formatting examples:

MessageOutput
Length: {length, number, ::measure-unit/length-meter}Length: 5 m
Balance: {price, number, ::currency/USD compact-long}Balance: $1 thousand

Date and time formatting

Format Date objects or Luxon DateTime instances using the {key, date, format} or {key, time, format} syntax.

resources/lang/en/messages.json
{
  "shipment_update": "Your package will arrive on {expectedDate, date, medium}"
}
resources/views/order.edge
{{ t('shipment_update', { expectedDate: luxonDateTime }) }}
{{-- Output: Your package will arrive on Oct 16, 2023 --}}

For time formatting:

resources/lang/en/messages.json
{
  "appointment": "You have an appointment today at {appointmentAt, time, ::h:m a}"
}
You have an appointment today at 2:48 PM

Supported date/time patterns

ICU provides many patterns, but only the following are available via the ECMA402 Intl API:

SymbolDescription
GEra designator
yYear
MMonth in year
LStand-alone month in year
dDay in month
EDay of week
eLocal day of week (e..eee not supported)
cStand-alone local day of week (c..ccc not supported)
aAM/PM marker
hHour 1-12
HHour 0-23
KHour 0-11
kHour 1-24
mMinute
sSecond
zTime Zone

Plural rules

ICU has first-class support for pluralization. The plural format selects different text based on a numeric value.

resources/lang/en/messages.yaml
cart_summary: >
  You have {itemsCount, plural,
    =0 {no items}
    one {1 item}
    other {# items}
  } in your cart
resources/views/cart.edge
{{ t('messages.cart_summary', { itemsCount: 1 }) }}
{{-- Output: You have 1 item in your cart --}}

The # token is a placeholder for the numeric value, formatted according to locale rules.

resources/views/cart.edge
{{ t('messages.cart_summary', { itemsCount: 1000 }) }}
{{-- Output: You have 1,000 items in your cart --}}

Plural categories

The {key, plural, matches} syntax matches values to these categories:

CategoryWhen used
zeroLanguages with grammar for zero items (Arabic, Latvian)
oneLanguages with grammar for one item (many Western languages)
twoLanguages with grammar for two items (Arabic, Welsh)
fewLanguages with grammar for small numbers (varies by language)
manyLanguages with grammar for larger numbers (Arabic, Polish, Russian)
otherDefault category when no other matches; used for "plural" in languages like English
=valueMatches a specific numeric value exactly

Select format

The select format chooses output by matching a value against multiple options. This is useful for gender-specific text or other categorical choices.

resources/lang/en/messages.yaml
auto_reply: >
  {gender, select,
    male {He}
    female {She}
    other {They}
  } will respond shortly.
resources/views/contact.edge
{{ t('messages.auto_reply', { gender: 'female' }) }}
{{-- Output: She will respond shortly. --}}

Select ordinal format

The selectordinal format handles ordinal numbers (1st, 2nd, 3rd, etc.) according to locale rules.

resources/lang/en/messages.yaml
anniversary_greeting: >
  It's my {years, selectordinal,
    one {#st}
    two {#nd}
    few {#rd}
    other {#th}
  } anniversary
resources/views/profile.edge
{{ t('messages.anniversary_greeting', { years: 2 }) }}
{{-- Output: It's my 2nd anniversary --}}

The ordinal categories are the same as plural categories (zero, one, two, few, many, other, =value).

Formatting values

The i18n package provides methods for formatting values according to locale conventions. These methods use the Node.js Intl API with optimized performance.

formatNumber

Format numeric values using the Intl.NumberFormat class.

app/services/example_service.ts
import i18nManager from '@adonisjs/i18n/services/main'

i18nManager
  .locale('en')
  .formatNumber(123456.789, {
    maximumSignificantDigits: 3
  })
// "123,000"

See: Intl.NumberFormat options

formatCurrency

Format a numeric value as currency. This method sets style: 'currency' automatically.

app/services/example_service.ts
import i18nManager from '@adonisjs/i18n/services/main'

i18nManager
  .locale('en')
  .formatCurrency(200, {
    currency: 'USD'
  })
// "$200.00"

formatDate

Format a Date object or Luxon DateTime instance using the Intl.DateTimeFormat class.

app/services/example_service.ts
import i18nManager from '@adonisjs/i18n/services/main'
import { DateTime } from 'luxon'

i18nManager
  .locale('en')
  .formatDate(new Date(), {
    dateStyle: 'long'
  })
// "January 10, 2026"

i18nManager
  .locale('en')
  .formatDate(DateTime.local(), {
    dateStyle: 'long'
  })
// "January 10, 2026"

See: Intl.DateTimeFormat options

formatTime

Format a date value as a time string. This method sets timeStyle: 'medium' by default.

app/services/example_service.ts
import i18nManager from '@adonisjs/i18n/services/main'

i18nManager
  .locale('en')
  .formatTime(new Date())
// "2:48:30 PM"

formatRelativeTime

Format a value as relative time (like "in 2 hours" or "3 days ago") using the Intl.RelativeTimeFormat class.

app/services/example_service.ts
import { DateTime } from 'luxon'
import i18nManager from '@adonisjs/i18n/services/main'

const futureDate = DateTime.local().plus({ hours: 2 })

i18nManager
  .locale('en')
  .formatRelativeTime(futureDate, 'hours')
// "in 2 hours"

Use the 'auto' unit to automatically select the most appropriate unit.

app/services/example_service.ts
import { DateTime } from 'luxon'
import i18nManager from '@adonisjs/i18n/services/main'

const nearFuture = DateTime.local().plus({ hours: 2 })
i18nManager.locale('en').formatRelativeTime(nearFuture, 'auto')
// "in 2 hours"

const farFuture = DateTime.local().plus({ hours: 200 })
i18nManager.locale('en').formatRelativeTime(farFuture, 'auto')
// "in 8 days"

See: Intl.RelativeTimeFormat options

formatPlural

Find the plural category for a number using the Intl.PluralRules class. This is useful when you need to handle pluralization in code rather than in translation strings.

app/services/example_service.ts
import i18nManager from '@adonisjs/i18n/services/main'

i18nManager.locale('en').formatPlural(0)  // "other"
i18nManager.locale('en').formatPlural(1)  // "one"
i18nManager.locale('en').formatPlural(2)  // "other"

See: Intl.PluralRules options

formatList

Format an array of strings as a human-readable list using the Intl.ListFormat class.

app/services/example_service.ts
import i18nManager from '@adonisjs/i18n/services/main'

i18nManager
  .locale('en')
  .formatList(['Me', 'myself', 'I'], { type: 'conjunction' })
// "Me, myself, and I"

i18nManager
  .locale('en')
  .formatList(['5 hours', '3 minutes'], { type: 'unit' })
// "5 hours, 3 minutes"

See: Intl.ListFormat options

formatDisplayNames

Format codes for currencies, languages, regions, and calendars as human-readable names using the Intl.DisplayNames class.

app/services/example_service.ts
import i18nManager from '@adonisjs/i18n/services/main'

i18nManager
  .locale('en')
  .formatDisplayNames('INR', { type: 'currency' })
// "Indian Rupee"

i18nManager
  .locale('en')
  .formatDisplayNames('en-US', { type: 'language' })
// "American English"

See: Intl.DisplayNames options

Configuring the i18n Ally VSCode extension

The i18n Ally extension for VSCode provides inline translation previews, missing translation detection, and quick editing. To configure it for AdonisJS, create two files in your .vscode directory.

mkdir -p .vscode
touch .vscode/i18n-ally-custom-framework.yml
touch .vscode/settings.json
.vscode/settings.json
{
  "i18n-ally.localesPaths": [
    "resources/lang"
  ],
  "i18n-ally.keystyle": "nested",
  "i18n-ally.namespace": true,
  "i18n-ally.editor.preferEditor": true,
  "i18n-ally.refactor.templates": [
    {
      "templates": [
        "{{ t('{key}'{args}) }}"
      ],
      "include": [
        "**/*.edge"
      ]
    }
  ]
}
.vscode/i18n-ally-custom-framework.yml
languageIds:
  - edge
usageMatchRegex:
  - "[^\\w\\d]t\\(['\"`]({key})['\"`]"
sortKeys: true

Listening for missing translations

Subscribe to the i18n:missing:translation event to track missing translations in your application. This is useful for logging or alerting during development.

start/events.ts
import emitter from '@adonisjs/core/services/emitter'

emitter.on('i18n:missing:translation', function (event) {
  console.log(event.identifier)   // The translation key that was missing
  console.log(event.hasFallback)  // Whether a fallback was used
  console.log(event.locale)       // The locale that was requested
})

Reloading translations

The i18n package loads translation files at startup and caches them in memory. If you modify translation files while the application is running, use the reloadTranslations method to refresh the cache.

app/controllers/admin_controller.ts
import i18nManager from '@adonisjs/i18n/services/main'

export default class AdminController {
  async refreshTranslations() {
    await i18nManager.reloadTranslations()
  }
}

Advanced: Creating a custom translation loader

A translation loader is responsible for loading translations from a persistent store. The default filesystem loader reads from resources/lang, but you can create custom loaders to read from a database, remote API, or any other source.

A loader must implement the TranslationsLoaderContract interface with a load method that returns translations grouped by locale.

app/i18n/db_loader.ts
import type {
  LoaderFactory,
  TranslationsLoaderContract,
} from '@adonisjs/i18n/types'

/**
 * Configuration options for the loader
 */
export type DbLoaderConfig = {
  connection: string
  tableName: string
}

/**
 * The loader implementation
 */
export class DbLoader implements TranslationsLoaderContract {
  constructor(public config: DbLoaderConfig) {}

  async load() {
    /**
     * Query your database here and return translations
     * grouped by locale with flattened keys
     */
    return {
      en: {
        'messages.greeting': 'Hello world',
        'messages.farewell': 'Goodbye',
      },
      fr: {
        'messages.greeting': 'Bonjour le monde',
        'messages.farewell': 'Au revoir',
      }
    }
  }
}

/**
 * Factory function to reference the loader in config
 */
export function dbLoader(config: DbLoaderConfig): LoaderFactory {
  return () => {
    return new DbLoader(config)
  }
}

Using the custom loader

Reference the loader in your config file using the factory function.

config/i18n.ts
import { defineConfig } from '@adonisjs/i18n'
import { dbLoader } from '#app/i18n/db_loader'

const i18nConfig = defineConfig({
  defaultLocale: 'en',
  formatter: formatters.icu(),
  loaders: [
    dbLoader({
      connection: 'pg',
      tableName: 'translations'
    })
  ]
})

export default i18nConfig

Advanced: Creating a custom translation formatter

A translation formatter processes translation strings according to a specific format. The default ICU formatter handles ICU message syntax, but you can create custom formatters for other formats like Fluent.

A formatter must implement the TranslationsFormatterContract interface with a format method.

app/i18n/fluent_formatter.ts
import type {
  FormatterFactory,
  TranslationsFormatterContract,
} from '@adonisjs/i18n/types'

/**
 * The formatter implementation
 */
export class FluentFormatter implements TranslationsFormatterContract {
  format(
    message: string,
    locale: string,
    data?: Record<string, any>
  ): string {
    /**
     * Process the message using your chosen format
     * and return the formatted string
     */
    return message // Your formatting logic here
  }
}

/**
 * Factory function to reference the formatter in config
 */
export function fluentFormatter(): FormatterFactory {
  return () => {
    return new FluentFormatter()
  }
}

Using the custom formatter

Reference the formatter in your config file using the factory function.

config/i18n.ts
import { defineConfig } from '@adonisjs/i18n'
import { fluentFormatter } from '#app/i18n/fluent_formatter'

const i18nConfig = defineConfig({
  defaultLocale: 'en',
  formatter: fluentFormatter()
})

export default i18nConfig