# Internationalization

Source: https://docs.nestjs.com/application/i18n

`@nestjs/i18n` translates what a Nest application sends back (responses, errors, validation messages, GraphQL fields) into the language of each request.

An online store for cat food and supplies sells to customers in the United States, Poland and Germany, and its order API should answer each of them in their own language. In this tutorial, you'll translate order confirmations (with correct Polish plurals), error messages, validation messages (from `class-validator`, or from Zod) and GraphQL fields. You'll also format prices and dates for each locale.

The package resolves the locale once per request and keeps it in an `AsyncLocalStorage` store. Services, exceptions, pipes and GraphQL field resolvers translate without access to the request object, and every provider stays a singleton. It works the same on Express and Fastify, and for microservices and WebSocket gateways too.

#### Installation

This tutorial extends the store's order API, which serves customers in English, Polish, and German. Orders are plain objects kept in memory. The example seeds order #1001: two bags of salmon kibble and three packs of clumping litter, $97.95 in total. It was placed on September 14, 2026 and has shipped since.

Install the package:

```bash
$ npm i --save @nestjs/i18n
```

The first section derives a type from a JSON file, so enable `resolveJsonModule` in your `tsconfig.json`.

#### Add catalogs and register the module

Translations live in JSON files, one directory per locale. Each file becomes a namespace named after the file, so the `notFound` message in `orders.json` has the key `orders.notFound`.

```text
src
├── i18n
│   ├── de
│   │   ├── orders.json
│   │   └── validation.json
│   ├── en
│   │   ├── orders.json
│   │   └── validation.json
│   ├── pl
│   │   ├── orders.json
│   │   └── validation.json
│   └── translations.ts
├── orders
└── app.module.ts
```

Here's the English catalog for orders. Words in curly braces are placeholders, filled from the arguments you pass when translating. `items` is a plural message, which [Translate order confirmations](/application/i18n#translate-order-confirmations) explains.

```json

{
  "items": {
    "one": "{count} item",
    "other": "{count} items"
  },
  "confirmed": "Thank you! Order #{id} is confirmed: {items}.",
  "notFound": "Order #{id} was not found.",
  "invalidId": "The order number must be a whole number.",
  "unknownProduct": "Product {id} is not in our catalog.",
  "tooManyItems": {
    "one": "{path} must not exceed {count} item per product",
    "other": "{path} must not exceed {count} items per product"
  },
  "unsupportedCountry": "We only ship to the United States, Poland and Germany.",
  "status": {
    "pending": "Awaiting payment",
    "paid": "Paid",
    "shipped": "Shipped"
  }
}
```

The TypeScript compiler doesn't copy JSON files to `dist`. Add them to the [assets](/cli/monorepo#assets) in `nest-cli.json`:

```json
{
  "compilerOptions": {
    "assets": ["i18n/**/*.json"],
    "watchAssets": true
  }
}
```

Now register `I18nModule` in the root module:

```typescript

import { Module } from '@nestjs/common';
import {
  AcceptLanguageLocaleResolver,
  HeaderLocaleResolver,
  I18nModule,
  JsonI18nLoader,
  QueryLocaleResolver,
} from '@nestjs/i18n';
import { join } from 'node:path';
import { OrdersModule } from './orders/orders.module.js';

@Module({
  imports: [
    I18nModule.forRoot({
      defaultLocale: 'en',
      supportedLocales: ['en', 'pl', 'de'],
      fallbacks: { 'de-AT': 'de' },
      loader: new JsonI18nLoader({
        path: join(import.meta.dirname, 'i18n'),
      }),
      resolvers: [
        new QueryLocaleResolver('lang'),
        new HeaderLocaleResolver('x-lang'),
        new AcceptLanguageLocaleResolver(),
      ],
    }),
    OrdersModule,
  ],
})
export class AppModule {}
```

The module is global, and it mounts a middleware on every route that resolves the locale before guards, interceptors, pipes and handlers run. Resolution works like this:

- **Resolvers run in order.** Each returns one or more candidates, and the first candidate that matches a supported locale wins. Here, `?lang=pl` beats an `x-lang` header, which beats `Accept-Language`.
- **`Accept-Language` is parsed properly.** Languages are ordered by their `q` values, and unsupported ones are skipped, so `fr-CH, fr;q=0.9, de;q=0.8` resolves to `de`.
- **Regions match their base language.** `pl-PL` resolves to `pl`. Nothing to configure.
- **`fallbacks` keeps a region as a locale of its own.** `de-AT` resolves to `de-AT`, and its messages come from `de`. The locale matters when [formatting prices and dates](/application/i18n#format-prices-and-dates): Austrian customers get Austrian number formatting. A region that isn't listed, such as `de-CH`, resolves to plain `de`.
- **Nothing matches, or there's no request at all** (a cron job, a queue consumer): the `defaultLocale` is used.

`CookieLocaleResolver` reads a cookie instead, `lang` by default, for a site that remembers the language a visitor picked. [Use the customer's saved language](/application/i18n#use-the-customers-saved-language) adds a resolver of your own.

Every response says which locale it was written in. The middleware sets `Content-Language` to the resolved locale and adds the headers the resolvers read to `Vary`, here `Vary: x-lang, Accept-Language`, so caches keep one copy per language. Set `responseHeaders: false` to turn this off.

`supportedLocales` is optional and defaults to the directories the loader finds. Pinning it means a stray directory never becomes a locale.

Finally, give your keys a type. The English catalog is the source of truth, and a type-only import reads its shape at compile time without loading the file. The `declare module` block registers that shape with the package:

```typescript

import type orders from './en/orders.json';
import type validation from './en/validation.json';

export interface Translations {
  orders: typeof orders;
  validation: typeof validation;
}

declare module '@nestjs/i18n' {
  interface I18nTypes {
    translations: Translations;
  }
}
```

From now on, `I18nService` and the helper functions you'll use to localize exceptions and validation messages, `t()`, `i18nValidationMessage()` and `i18nIssueMessage()`, accept only keys that exist in the English catalog. A typo such as `orders.notFund` fails to compile, and so does `orders.status`, because it's a namespace and not a message. A provider that translates from catalogs of another shape, such as a library's, injects `I18nService<OtherTranslations>`, which checks keys against `OtherTranslations` instead.

#### Translate order confirmations

When a customer places an order, the API confirms it with a message that includes the number of items. Inject `I18nService` into `OrdersService` and call `t()`:

```typescript

@Injectable()
export class OrdersService {
  // ...
  constructor(private readonly i18nService: I18nService) {}

  create(userId: number, dto: CreateOrderDto) {
    const order: Order = {
      id: this.nextId++,
      userId,
      items: dto.items,
      total: this.priceOf(dto.items),
      status: 'pending',
      createdAt: new Date(),
    };
    this.orders.push(order);

    return {
      id: order.id,
      message: this.i18nService.t('orders.confirmed', {
        args: { id: order.id, items: this.describeItems(order) },
      }),
    };
  }

  describeItems(order: Order): string {
    const count = order.items.reduce((sum, item) => sum + item.quantity, 0);
    return this.i18nService.t('orders.items', { args: { count } });
  }
  // ...
}
```

You don't pass a locale to `t()`. It reads the one resolved for the current request, even though `OrdersService` is a singleton that never sees the request. Set the `locale` option only to override it, for example when a background job emails a customer in the language saved on their profile. The option is matched the way a request locale is: `pl-PL` becomes `pl`, and a locale you don't support becomes the default locale, so an outdated profile value can't break the job.

The controller has nothing locale-specific in it:

```typescript

@Controller('orders')
export class OrdersController {
  constructor(private readonly ordersService: OrdersService) {}

  @Post()
  create(@Body() dto: CreateOrderDto) {
    // The Authentication tutorial replaces the fixed customer with the signed-in user.
    return this.ordersService.create(1, dto);
  }
}
```

English and German have two plural forms. Polish has four, and which one a number takes depends on its last digits. A plural message is an object keyed by plural category:

```json

{
  "items": {
    "one": "{count} produkt",
    "few": "{count} produkty",
    "many": "{count} produktów",
    "other": "{count} produktu"
  },
  "confirmed": "Dziękujemy! Zamówienie #{id} zostało przyjęte: {items}.",
  "notFound": "Nie znaleziono zamówienia #{id}.",
  "invalidId": "Numer zamówienia musi być liczbą całkowitą.",
  "unknownProduct": "Produktu {id} nie ma w naszym katalogu.",
  "tooManyItems": {
    "one": "{path}: można zamówić najwyżej {count} sztukę jednego produktu",
    "few": "{path}: można zamówić najwyżej {count} sztuki jednego produktu",
    "many": "{path}: można zamówić najwyżej {count} sztuk jednego produktu",
    "other": "{path}: można zamówić najwyżej {count} sztuki jednego produktu"
  },
  "unsupportedCountry": "Wysyłamy tylko do Stanów Zjednoczonych, Polski i Niemiec.",
  "status": {
    "pending": "Oczekuje na płatność",
    "paid": "Opłacone",
    "shipped": "Wysłane"
  }
}
```

When the arguments include a numeric `count`, the service picks the form with `Intl.PluralRules` for the message's locale. If a category is missing, `other` is used. The German file mirrors the English one, with `one` and `other` forms only.

| Items | English | Polish | German |
| --- | --- | --- | --- |
| 1 | 1 item | 1 produkt | 1 Produkt |
| 2 | 2 items | 2 produkty | 2 Produkte |
| 5 | 5 items | 5 produktów | 5 Produkte |
| 22 | 22 items | 22 produkty | 22 Produkte |

The confirmation is composed from two messages: the plural phrase is translated first and then inserted into the sentence. Placeholders can't nest, so this is how you combine them.

A placeholder's name is made of letters, digits and underscores. A placeholder you pass no argument for stays in the text as written, and so does any other text in braces. To write a brace next to a name, double it: <code>{{id}}</code> prints <code>{id}</code>. For select, ordinals and exact matches, [use ICU message syntax](/application/i18n#use-icu-message-syntax).

> info **Hint** Only the `count` argument selects a plural form, so name it `count` in every plural message you translate with `t()`. Thanks to the registered catalog type, `t()` doesn't compile without it for plural messages. Validation messages get a `count` automatically, as [Localize validation messages](/application/i18n#localize-validation-messages) shows.

#### Localize exceptions

A missing order should produce a 404 in the customer's language. Translate the message when you throw:

```typescript

findOne(id: number): Order {
  const order = this.orders.find((order) => order.id === id);
  if (!order) {
    throw new NotFoundException(this.i18nService.t('orders.notFound', { args: { id } }));
  }
  return order;
}
```

The exception carries the finished text, so there is nothing to configure: Nest's default exception filter, your own filters and GraphQL error formatting all receive the translated message. `GET /orders/999` with `x-lang: pl` returns:

```json
{
  "message": "Nie znaleziono zamówienia #999.",
  "error": "Not Found",
  "statusCode": 404
}
```

Code that has no `I18nService` at hand can use the `t()` function instead. It translates for the current request in the same way. Built-in pipes such as `ParseIntPipe` produce English messages, so give the pipe that parses order IDs an exception factory:

```typescript

const orderIdPipe = new ParseIntPipe({
  exceptionFactory: () => new BadRequestException(t('orders.invalidId')),
});

@Controller('orders')
export class OrdersController {
  constructor(private readonly ordersService: OrdersService) {}
  // ...

  @Get(':id')
  findOne(@Param('id', orderIdPipe) id: number) {
    return this.ordersService.getSummary(id);
  }
}
```

The pipe is created once, outside dependency injection, and still answers each request in its own language. You'll write `getSummary()` in [Format prices and dates](/application/i18n#format-prices-and-dates).

If your API shapes errors with its own [exception filter](/exception-filters#binding-filters), for example as RFC 9457 problem details, the filter reads the translated message like any other:

```typescript

import {
  Catch,
  NotFoundException,
  type ArgumentsHost,
  type ExceptionFilter,
} from '@nestjs/common';
import { HttpAdapterHost } from '@nestjs/core';

@Catch(NotFoundException)
export class ProblemDetailsFilter implements ExceptionFilter {
  constructor(private readonly httpAdapterHost: HttpAdapterHost) {}

  catch(exception: NotFoundException, host: ArgumentsHost) {
    const { httpAdapter } = this.httpAdapterHost;
    const response = host.switchToHttp().getResponse();
    httpAdapter.setHeader(response, 'Content-Type', 'application/problem+json');
    httpAdapter.reply(
      response,
      { type: 'about:blank', title: 'Not Found', status: 404, detail: exception.message },
      404,
    );
  }
}
```

With the filter registered, the same request returns:

```json
{
  "type": "about:blank",
  "title": "Not Found",
  "status": 404,
  "detail": "Nie znaleziono zamówienia #999."
}
```

#### Localize validation messages

This step assumes your application validates request bodies with `class-validator`; if it validates with Zod or another Standard Schema library, read on to [With Zod](#with-zod). `I18nValidationPipe` is a drop-in replacement for Nest's [`ValidationPipe`](/application/validation#using-the-built-in-validationpipe): it accepts the same options and only translates the messages. Bind it where `ValidationPipe` was, here as an `APP_PIPE` provider of `AppModule`:

```typescript

providers: [
  {
    provide: APP_PIPE,
    useValue: new I18nValidationPipe({ whitelist: true, transform: true }),
  },
],
```

Start with the DTO for new orders. It uses `class-validator` decorators without any messages:

```typescript

import { i18nValidationMessage } from '@nestjs/i18n';
import { Type } from 'class-transformer';
import { ArrayNotEmpty, IsInt, IsString, Max, Min, ValidateNested } from 'class-validator';

export class OrderItemDto {
  @IsString()
  productId: string;

  @IsInt()
  @Min(1)
  @Max(10, { message: i18nValidationMessage('orders.tooManyItems') })
  quantity: number;
}

export class CreateOrderDto {
  @ArrayNotEmpty()
  @ValidateNested({ each: true })
  @Type(() => OrderItemDto)
  items: OrderItemDto[];
}
```

A constraint without a `message` is looked up in the `validation` namespace by constraint name: `@IsInt()` reads `validation.isInt`, and `@Min()` reads `validation.min`. `i18nValidationMessage()` points a single constraint at a key of your choice, here a store-specific message for the item limit. Messages you write as plain strings are left alone. A message can use these placeholders:

- `property`: the field name, such as `quantity`.
- `path`: the dotted path from the DTO, such as `items.1.quantity`. For a top-level field, it's the same as `property`.
- `value`: the rejected value, when it's a string, number or boolean.
- `constraint1`, `constraint2` and so on: the decorator's arguments. A list, such as the values `@IsIn()` accepts, reads `US, PL, DE`.
- `count`: the first numeric argument, so a plural message agrees with it. `@Max(10)` selects the form for 10.

The item limit is a plural message (see the Polish catalog in [Translate order confirmations](/application/i18n#translate-order-confirmations)), so `@Max(10)` reads "10 sztuk" in Polish. Here is the Polish `validation` catalog for the constraints this tutorial uses. `minLength` is a plural message too, so `@MinLength(2)` reads "2 znaki" and `@MinLength(5)` "5 znaków":

```json

{
  "isInt": "{path} musi być liczbą całkowitą",
  "isString": "{path} musi być tekstem",
  "min": "{path} musi wynosić co najmniej {constraint1}",
  "arrayNotEmpty": "{path} musi zawierać co najmniej jeden element",
  "minLength": {
    "one": "{path} musi mieć co najmniej {count} znak",
    "few": "{path} musi mieć co najmniej {count} znaki",
    "many": "{path} musi mieć co najmniej {count} znaków",
    "other": "{path} musi mieć co najmniej {count} znaku"
  },
  "matches": "{path} ma nieprawidłowy format"
}
```

A constraint that has no translation keeps the `class-validator` message, so you can translate the constraints you use one at a time.

For nested objects, Nest's `ValidationPipe` puts the parent path in front of each message. `I18nValidationPipe` leaves translated messages as they are, so each message decides for itself: `path` gives `items.1.quantity`, and `property` gives just `quantity`. Messages without a translation keep the prefix.

Customers set a shipping address on an order. Its DTO relies on the `minLength` and `matches` entries of the catalog, and names a store-specific message for the country:

```typescript

import { i18nValidationMessage } from '@nestjs/i18n';
import { IsIn, Matches, MinLength } from 'class-validator';

export class ShippingAddressDto {
  @MinLength(2)
  recipient: string;

  @MinLength(2)
  city: string;

  @Matches(/^[0-9-]{5,6}$/)
  postalCode: string;

  @IsIn(['US', 'PL', 'DE'], { message: i18nValidationMessage('orders.unsupportedCountry') })
  country: 'US' | 'PL' | 'DE';
}
```

```typescript

@Put(':id/shipping-address')
updateShippingAddress(
  @Param('id', orderIdPipe) id: number,
  @Body() address: ShippingAddressDto,
) {
  return this.ordersService.updateShippingAddress(id, address);
}
```

A German customer who sends a one-letter recipient, a four-digit postal code and `AT` as the country gets three translated messages back. The [Try it](#try-it) section shows the response.

##### With Zod

If your application validates with Zod, Valibot, ArkType or another [Standard Schema](https://standardschema.dev/) library, bind `I18nStandardSchemaValidationPipe` instead of `I18nValidationPipe`. It's the drop-in replacement for Nest's [`StandardSchemaValidationPipe`](/application/validation#schema-based-validation), and an application binds one of the two pipes, not both:

```typescript

providers: [{ provide: APP_PIPE, useValue: new I18nStandardSchemaValidationPipe() }],
```

Here are the same inputs as Zod schemas. Library messages are plain strings, so `i18nIssueMessage()` encodes a translation key in the message itself, which works with any Standard Schema library:

```typescript

import { i18nIssueMessage } from '@nestjs/i18n';
import { z } from 'zod';

export const orderItemSchema = z.object({
  productId: z.string(),
  quantity: z.number().int().min(1).max(10, i18nIssueMessage('orders.tooManyItems')),
});

export const createOrderSchema = z.object({
  items: z.array(orderItemSchema).nonempty(),
});
export type CreateOrderDto = z.infer<typeof createOrderSchema>;

export const shippingAddressSchema = z.object({
  recipient: z.string().min(2),
  city: z.string().min(2),
  postalCode: z.string().regex(/^[0-9-]{5,6}$/),
  country: z.enum(['US', 'PL', 'DE'], i18nIssueMessage('orders.unsupportedCountry')),
});
export type ShippingAddress = z.infer<typeof shippingAddressSchema>;
```

The body parameters declare their schema instead of a class:

```typescript

import {
  createOrderSchema,
  shippingAddressSchema,
  type CreateOrderDto,
  type ShippingAddress,
} from './orders.schemas.js';
// ...
@Post()
create(@Body({ schema: createOrderSchema }) dto: CreateOrderDto) {
  // ...
}

@Put(':id/shipping-address')
updateShippingAddress(
  @Param('id', orderIdPipe) id: number,
  @Body({ schema: shippingAddressSchema }) address: ShippingAddress,
) {
  return this.ordersService.updateShippingAddress(id, address);
}
```

An issue without an explicit key is looked up by the code the library assigns, from the most specific key to the least:

- **Format first.** A failed regex reads `validation.invalid_format.regex`.
- **Then the origin.** Zod reports `too_small` for short strings, small numbers and short arrays alike, and says which in `origin`. A short string reads `validation.too_small.string`, so the catalog can talk about characters without getting numbers wrong.
- **Then the code alone**, such as `validation.too_small`.

Valibot's issue `type` works the same way. Messages can use the `property` and `path` placeholders as above, the library's own text as `message`, and the issue's parameters, such as `minimum` or `expected`. `count` holds the numeric `minimum` or `maximum`, so "2 znaki" and "5 znaków" both come out right, and so does `orders.tooManyItems` for `.max(10)`. Here is the Polish catalog again, with the issue-code entries in front of the constraint names:

```json

{
  "invalid_type": "{path} musi być typu {expected}",
  "too_small": {
    "string": {
      "one": "{path} musi mieć co najmniej {count} znak",
      "few": "{path} musi mieć co najmniej {count} znaki",
      "many": "{path} musi mieć co najmniej {count} znaków",
      "other": "{path} musi mieć co najmniej {count} znaku"
    },
    "number": "{path} musi wynosić co najmniej {minimum}",
    "array": "{path} musi zawierać co najmniej jeden element"
  },
  "invalid_format": {
    "regex": "{path} ma nieprawidłowy format"
  },
  "isInt": "{path} musi być liczbą całkowitą",
  "isString": "{path} musi być tekstem",
  "min": "{path} musi wynosić co najmniej {constraint1}",
  "arrayNotEmpty": "{path} musi zawierać co najmniej jeden element",
  "minLength": {
    "one": "{path} musi mieć co najmniej {count} znak",
    "few": "{path} musi mieć co najmniej {count} znaki",
    "many": "{path} musi mieć co najmniej {count} znaków",
    "other": "{path} musi mieć co najmniej {count} znaku"
  },
  "matches": "{path} ma nieprawidłowy format"
}
```

With these entries, the invalid shipping address from the [Try it](#try-it) section gets the same three German messages as with the class, and the invalid order reads:

```json
{
  "message": [
    "items.0.quantity musi wynosić co najmniej 1",
    "items.1.productId musi być typu string",
    "items.1.quantity: można zamówić najwyżej 10 sztuk jednego produktu"
  ],
  "error": "Bad Request",
  "statusCode": 400
}
```

Issues without a translation keep Nest's usual format, the path followed by the library's message.

#### Localize GraphQL fields

This step applies to an application that serves GraphQL with `@nestjs/graphql`. The mobile app reads orders over GraphQL, through the Apollo driver and a [code first](/graphql/quick-start#code-first) schema registered in `AppModule`:

```typescript

GraphQLModule.forRoot<ApolloDriverConfig>({
  driver: ApolloDriver,
  autoSchemaFile: true,
}),
```

The object type exposes raw order data:

```typescript

import { Field, Float, Int, ObjectType } from '@nestjs/graphql';

@ObjectType('Order')
export class OrderModel {
  @Field(() => Int)
  id: number;

  @Field()
  status: string;

  @Field(() => Float)
  total: number;
}
```

Localized fields are computed by field resolvers, with the same `I18nService` the REST controller uses:

```typescript

import { Args, Int, Parent, Query, ResolveField, Resolver } from '@nestjs/graphql';
import { I18nService } from '@nestjs/i18n';
import type { Order } from './order.entity.js';
import { OrderModel } from './order.model.js';
import { OrdersService } from './orders.service.js';

@Resolver(() => OrderModel)
export class OrdersResolver {
  constructor(
    private readonly ordersService: OrdersService,
    private readonly i18nService: I18nService,
  ) {}

  @Query(() => OrderModel)
  order(@Args('id', { type: () => Int }) id: number) {
    return this.ordersService.findOne(id);
  }

  @ResolveField(() => String)
  statusLabel(@Parent() order: Order) {
    return this.i18nService.t(`orders.status.${order.status}`);
  }

  @ResolveField(() => String)
  items(@Parent() order: Order) {
    return this.ordersService.describeItems(order);
  }
}
```

The `statusLabel` key is a template literal built from the `OrderStatus` union, so the compiler still checks that every status has a label.

On Express, Apollo is mounted after Nest's middleware, so the locale middleware from [Add catalogs and register the module](/application/i18n#add-catalogs-and-register-the-module) wraps every GraphQL operation. `?lang`, `x-lang` and `Accept-Language` work exactly as they do for REST, in resolvers, field resolvers and guards. The `NotFoundException` thrown by `findOne()` reaches the client translated, in `errors[0].message`.

> warning **Warning** The locale middleware doesn't wrap subscriptions, which run over WebSockets, or a GraphQL driver that is mounted outside it. That includes the common case of a [global prefix](/faq/global-prefix): Nest mounts the middleware under the prefix, while Apollo stays at `/graphql`, so set `useGlobalPrefix: true` on `GraphQLModule` when you use one. Where the middleware doesn't run, the package falls back to a global interceptor, which reads the locale from the request on the GraphQL context (for a subscription, the WebSocket handshake) and runs after guards. Field resolvers, including those of subscription events, are only covered if you set `fieldResolverEnhancers: ['interceptors']`, guards don't see the locale, and responses carry no `Content-Language`.

#### Format prices and dates

The order summary shows the status, the number of items, the total and the order date, formatted the way each customer expects:

```typescript

getSummary(id: number) {
  const order = this.findOne(id);
  return {
    id: order.id,
    status: this.i18nService.t(`orders.status.${order.status}`),
    items: this.describeItems(order),
    total: this.i18nService.formatNumber(order.total, { style: 'currency', currency: 'USD' }),
    placedAt: this.i18nService.formatDate(order.createdAt, {
      dateStyle: 'long',
      timeZone: 'UTC',
    }),
  };
}
```

`formatNumber()` and `formatDate()` wrap `Intl.NumberFormat` and `Intl.DateTimeFormat` with the request's locale. They take the usual `Intl` options, plus a `locale` option that overrides the current locale, as in `t()`. For order #1001:

| Locale | `total` | `placedAt` |
| --- | --- | --- |
| `en` | $97.95 | September 14, 2026 |
| `pl` | 97,95 USD | 14 września 2026 |
| `de` | 97,95 $ | 14. September 2026 |
| `de-AT` | $ 97,95 | 14. September 2026 |

`de-AT` gets Austrian formatting because the module registration listed it in `fallbacks`. Its messages still come from the German catalog.

Two things the locale doesn't decide:

- **Currency.** The locale controls how an amount is written, not what it's worth. The store charges in US dollars, so every customer sees USD. Take the currency from the order or the store, never from the locale.
- **Time zone.** Without `timeZone`, dates are formatted in the server's time zone, which can shift the day. Pass the customer's time zone if you store one, or format in UTC as here.

#### Use the customer's saved language

Customers pick a language when they sign up, and the customer record keeps it in a `locale` field, like the `customers` table of the [mail chapter](/application/mail#send-it-in-the-customers-language). Here, `CustomersService` keeps customers in memory, as `OrdersService` keeps orders. For a signed-in customer, that choice should beat `Accept-Language`, which is only their browser's default. The guard of [`@nestjs/authentication`](/security/authentication), like a Passport guard, puts the signed-in user on `request.user`.

Resolvers run in the locale middleware, before guards, when nobody is signed in yet. A resolver that sets `afterGuards` runs after guards instead, and receives the `executionContext` of the call:

```typescript

import { Injectable } from '@nestjs/common';
import { LocaleResolver, type LocaleResolverInput } from '@nestjs/i18n';
import { CustomersService } from './customers.service.js';

@Injectable()
export class CustomerLocaleResolver extends LocaleResolver {
  // After guards, the authentication guard has put the signed-in user on the request
  readonly afterGuards = true;

  constructor(private readonly customersService: CustomersService) {
    super();
  }

  resolve({ executionContext }: LocaleResolverInput) {
    const request = executionContext?.switchToHttp().getRequest<{ user?: { id: number } }>();
    const user = request?.user;
    return user ? this.customersService.findOne(user.id)?.locale : undefined;
  }
}
```

Nest instantiates a resolver class, so it can inject providers. Put it into `resolvers` at the rank it should have, and import the module that provides what it injects:

```typescript

I18nModule.forRoot({
  // ...
  resolvers: [
    new QueryLocaleResolver('lang'),
    new HeaderLocaleResolver('x-lang'),
    CustomerLocaleResolver,
    new AcceptLanguageLocaleResolver(),
  ],
  imports: [CustomersModule],
  // ...
}),
```

The order of the resolvers still decides:

- **`?lang` and `x-lang` win.** They're a choice made for this request, by a language switcher link or an app setting. When one of them matches, the customer resolver doesn't run.
- **The saved language beats `Accept-Language`.** Zofia saved Polish, so she reads Polish in any browser. Lukas saved `de-AT`, which is matched like any candidate, so he gets Austrian formatting.
- **Anonymous customers, and customers without a supported saved language,** get what the resolvers below it find, here from `Accept-Language`.

Guards see the locale that the resolvers before guards found, and so does the response to an error a guard throws, such as a 401. The handler, and everything after it, sees the customer's. The resolver runs once per request, also for a GraphQL operation with several root fields. Over GraphQL, the request is at `GqlExecutionContext.create(executionContext).getContext().req`.

> info **Hint** A response to a signed-in customer now depends on who asks. Such responses are usually private anyway, but if a shared cache stores them, list the header that identifies the customer, such as `Authorization`, in the resolver's `varyHeaders`, and the middleware adds it to `Vary`.

When customers change their language, answer them in the new one right away. `I18nContext.setLocale()` changes the locale for the rest of the request, `Content-Language` included:

```typescript

@Controller('customers')
export class CustomersController {
  constructor(
    private readonly customersService: CustomersService,
    private readonly i18nContext: I18nContext,
    private readonly i18nService: I18nService,
  ) {}

  @Put('me/locale')
  updateLocale(@Req() request: { user: { id: number } }, @Body('locale') locale: string) {
    const supported = this.i18nContext.setLocale(locale);
    if (!supported) {
      throw new BadRequestException(this.i18nService.t('customers.unsupportedLocale'));
    }

    this.customersService.updateLocale(request.user.id, supported);
    return { locale: supported, message: this.i18nService.t('customers.localeUpdated') };
  }
}
```

`setLocale()` matches the locale the way resolution does and returns the match: `de-DE` becomes `de`. For a language the store doesn't write in, it returns `undefined` and leaves the locale as it was, so the error is in the language the customer is reading. The two messages live in a new `customers` catalog in each language, registered with `customers: typeof customers` in `Translations`:

```json

{
  "localeUpdated": "Erledigt! Ab jetzt schreiben wir Ihnen auf Deutsch.",
  "unsupportedLocale": "Wir schreiben nur auf Englisch, Polnisch und Deutsch."
}
```

#### Microservices and WebSocket gateways

Message handlers and WebSocket gateways don't pass through the HTTP middleware. There, the module resolves the locale in a global interceptor, with the same resolvers, so services, exceptions and the `t()` function work as they do for HTTP requests.

A message has no headers or query string, but resolvers receive the `executionContext`, so a resolver can read the locale a producer puts into the message itself. Add it to the `resolvers` of the microservice's `I18nModule`:

```typescript

import { LocaleResolver, type LocaleResolverInput } from '@nestjs/i18n';

export class MessageLocaleResolver extends LocaleResolver {
  resolve({ executionContext }: LocaleResolverInput) {
    if (executionContext?.getType() !== 'rpc') {
      return undefined;
    }
    return executionContext.switchToRpc().getData<{ locale?: string }>()?.locale;
  }
}
```

A resolver can read a message's transport metadata the same way, such as the headers of a NATS or Kafka message, from `executionContext.switchToRpc().getContext()`.

For a gateway, the built-in resolvers read the headers and query string of the connection's upgrade request. Socket.IO keeps that request as the handshake, and the resolvers find it there. The `ws` library, behind `WsAdapter` from `@nestjs/platform-ws`, keeps nothing, so store the request on `client.request` when a client connects (`WsAuthenticator` of `@nestjs/authentication` does the same):

```typescript

@WebSocketGateway({ path: '/orders/updates' })
export class OrderUpdatesGateway implements OnGatewayConnection {
  constructor(private readonly ordersService: OrdersService) {}

  handleConnection(client: WebSocket & { request?: IncomingMessage }, request: IncomingMessage) {
    // The ws library keeps no reference to the upgrade request, whose headers and query the resolvers read
    client.request = request;
  }

  @SubscribeMessage('orders.summary')
  summary(@MessageBody() id: number) {
    return { event: 'orders.summary', data: this.ordersService.getSummary(id) };
  }
}
```

A client that connects to `/orders/updates?lang=de` reads German summaries. Interceptors run after guards, so on these paths guards see the default locale, and an error a guard throws is in the default locale. Pipes and handlers see the resolved one, and resolvers with `afterGuards` need nothing extra: every resolver already runs after guards here.

#### Handle missing keys and reload catalogs

A missing translation should break the build in development and CI, and degrade quietly in production. Here's the final `AppModule`:

```typescript

import { ApolloDriver, type ApolloDriverConfig } from '@nestjs/apollo';
import { Module } from '@nestjs/common';
import { APP_PIPE } from '@nestjs/core';
import { GraphQLModule } from '@nestjs/graphql';
import {
  AcceptLanguageLocaleResolver,
  HeaderLocaleResolver,
  I18nModule,
  I18nValidationPipe,
  JsonI18nLoader,
  QueryLocaleResolver,
} from '@nestjs/i18n';
import { join } from 'node:path';
import { CustomerLocaleResolver } from './customers/customer-locale.resolver.js';
import { CustomersModule } from './customers/customers.module.js';
import { OrdersModule } from './orders/orders.module.js';

const env = process.env.NODE_ENV ?? 'development';

@Module({
  imports: [
    I18nModule.forRoot({
      defaultLocale: 'en',
      supportedLocales: ['en', 'pl', 'de'],
      fallbacks: { 'de-AT': 'de' },
      loader: new JsonI18nLoader({
        path: join(import.meta.dirname, 'i18n'),
        watch: env === 'development',
      }),
      resolvers: [
        new QueryLocaleResolver('lang'),
        new HeaderLocaleResolver('x-lang'),
        CustomerLocaleResolver,
        new AcceptLanguageLocaleResolver(),
      ],
      imports: [CustomersModule],
      missingKey: env === 'production' ? 'fallback' : 'throw',
    }),
    GraphQLModule.forRoot<ApolloDriverConfig>({
      driver: ApolloDriver,
      autoSchemaFile: true,
    }),
    CustomersModule,
    OrdersModule,
  ],
  providers: [
    {
      provide: APP_PIPE,
      useValue: new I18nValidationPipe({ whitelist: true, transform: true }),
    },
  ],
})
export class AppModule {}
```

The `missingKey` option decides what happens when a key isn't found for the requested locale:

- **`'throw'` outside production.** `t()` and `translate()` throw an `I18nMissingKeyError`, and the request fails with a 500. A Polish message that exists only in English fails too, because this policy never falls back to the default locale. Vitest sets `NODE_ENV` to `test`, so your test suite runs with this policy.
- **`'fallback'` in production.** A missing Polish message is taken from the default locale, English, and a warning names the key and the locale, once per pair. A key that exists nowhere returns the key itself and logs a warning, once per key.

The `de-AT` to `de` fallback and region-to-base matching apply under every policy. Validation messages looked up by constraint name or issue code never throw either: a constraint or issue without a translation keeps the library's message. Keys you name with `i18nValidationMessage()` or `i18nIssueMessage()` follow the policy, like `t()`. The other policies are `'key'`, `'empty'` and a function that receives the key and the locale.

> info **Hint** Fallback works per key. When messages are composed as in [Translate order confirmations](/application/i18n#translate-order-confirmations), a Polish customer can receive an English sentence around a Polish plural. The warning tells you it happened; the catalog test in [Testing](/application/i18n#testing) keeps it from shipping.

With `watch: true`, the JSON loader watches the catalog directory and reloads it when a file changes, so translators can edit messages without restarting the app. Enable it in development only:

- **Reloads are atomic.** The whole catalog is re-read and swapped in one step, so a request never sees a mix of old and new messages.
- **Broken files are ignored.** If a file fails to parse, the previous catalog stays in use, and the error is logged with the file path. The next valid save is picked up.
- **Watching stops on shutdown.** The watcher closes when the application closes. Call `app.enableShutdownHooks()` in `main.ts` to close it on termination signals as well.

With `watchAssets` enabled in `nest-cli.json`, the CLI copies edited catalogs to `dist`, where the loader reads them.

##### Options from configuration

The tutorial reads `process.env` directly, to stay short. In an application, load the environment through [`@nestjs/config`](/application/configuration) with a validation schema, and register the module with `forRootAsync()`. Its factory receives the injected `ConfigService` and returns the options:

```typescript

I18nModule.forRootAsync({
  inject: [ConfigService],
  useFactory: (configService: ConfigService) => {
    const env = configService.get<string>('NODE_ENV', 'development');
    return {
      defaultLocale: 'en',
      supportedLocales: ['en', 'pl', 'de'],
      fallbacks: { 'de-AT': 'de' },
      loader: new JsonI18nLoader({
        path: join(import.meta.dirname, 'i18n'),
        watch: env === 'development',
      }),
      missingKey: env === 'production' ? 'fallback' : 'throw',
    };
  },
  // The list has a class, which Nest instantiates, so it stays next to useFactory
  resolvers: [
    new QueryLocaleResolver('lang'),
    new HeaderLocaleResolver('x-lang'),
    CustomerLocaleResolver,
    new AcceptLanguageLocaleResolver(),
  ],
  imports: [CustomersModule],
}),
```

The factory can return loader, resolver and formatter instances, but not classes: Nest instantiates classes while it builds the module, before any factory runs. So `resolvers`, which lists `CustomerLocaleResolver`, stays at the top level with `imports`. A factory that returns a class, or an option set in both places, fails at startup with a message that names the option.

To keep the options in a class of their own, pass it as `useClass`. It implements `I18nOptionsFactory`, and Nest calls its `createI18nOptions()` method:

```typescript

@Injectable()
export class I18nConfigService implements I18nOptionsFactory {
  constructor(private readonly configService: ConfigService) {}

  createI18nOptions(): I18nModuleOptions {
    const env = this.configService.get<string>('NODE_ENV', 'development');
    return {
      defaultLocale: 'en',
      // ...
    };
  }
}
```

```typescript

I18nModule.forRootAsync({
  useClass: I18nConfigService,
  resolvers: [
    // ...
  ],
  imports: [CustomersModule],
}),
```

The options the module ends up with are injectable with the `I18N_MODULE_OPTIONS` token, for a provider of your own that depends on them.

##### A custom loader

Translators often work in a translation platform, which exports every catalog as one JSON document. A loader extends `I18nLoader`: `load()` returns the catalogs keyed by locale, and the optional `watch()` passes new ones to a callback, for hot reload. This one fetches the export at startup, and again every five minutes:

```typescript

import { Logger } from '@nestjs/common';
import { I18nLoader, type I18nCatalogs } from '@nestjs/i18n';

/** Catalogs a translation platform exports as one JSON document, fetched again every `interval` ms. */
export class RemoteI18nLoader extends I18nLoader {
  private readonly logger = new Logger(RemoteI18nLoader.name);

  constructor(
    private readonly url: string,
    private readonly interval = 5 * 60_000,
  ) {
    super();
  }

  async load(): Promise<I18nCatalogs> {
    const response = await fetch(this.url, { signal: AbortSignal.timeout(10_000) });
    if (!response.ok) {
      throw new Error(`GET ${this.url} answered ${response.status}`);
    }
    return (await response.json()) as I18nCatalogs;
  }

  watch(onChange: (catalogs: I18nCatalogs) => void): () => void {
    const timer = setInterval(() => {
      this.load().then(onChange, (err: Error) =>
        this.logger.warn(`Keeping the current catalogs: ${err.message}`),
      );
    }, this.interval);
    // Polling alone doesn't keep the process alive
    timer.unref();
    return () => clearInterval(timer);
  }
}
```

Pass an instance as `loader`. A failed fetch at startup fails the bootstrap, and a failed poll keeps the catalogs in use. The module calls `watch()` when it initializes, and the function `watch()` returns when it shuts down. New catalogs are checked like the first ones, and a set that doesn't fit, such as one without a catalog for the default locale, is logged and ignored. To read catalogs from your database, pass a loader class instead, which Nest instantiates with the repository it injects, and list the repository's module in `imports`.

#### Use ICU message syntax

The built-in syntax has placeholders, and plural forms as catalog objects. Translation platforms such as Crowdin and Lokalise often export catalogs in ICU MessageFormat instead, which also has exact matches such as `=0`, `select` (for a message that depends on a value, such as a pet's species), and `selectordinal` (1st, 2nd, 3rd). Here is the Polish `items` message in ICU:

```json
{
  "items": "{count, plural, one {# produkt} few {# produkty} many {# produktów} other {# produktu}}"
}
```

The `formatter` option plugs in another syntax. A formatter extends `I18nMessageFormatter` and implements `format()`, which receives the message, the arguments and the locale. This one hands the message to the [`intl-messageformat`](https://formatjs.github.io/docs/intl-messageformat/) library, which your application installs:

```typescript

import { I18nMessageFormatter } from '@nestjs/i18n';
import { IntlMessageFormat } from 'intl-messageformat';

export class IcuMessageFormatter extends I18nMessageFormatter {
  // Parsing a message costs far more than formatting it, and the catalogs hold a fixed set
  private readonly compiled = new Map<string, IntlMessageFormat>();

  format(message: string, args: Record<string, unknown>, locale: string): string {
    const key = `${locale}\u0000${message}`;
    let compiled = this.compiled.get(key);
    if (!compiled) {
      compiled = new IntlMessageFormat(message, locale, undefined, { ignoreTag: true });
      this.compiled.set(key, compiled);
    }
    return String(compiled.format<unknown>(args));
  }
}
```

`ignoreTag` keeps `<` and `>` as text, where the library would otherwise read rich-text tags. Pass an instance, or a class that Nest instantiates, as `formatter`:

```typescript

I18nModule.forRoot({
  // ...
  formatter: new IcuMessageFormatter(),
}),
```

With the formatter in place:

- **The tutorial's catalogs work unchanged.** A placeholder is a simple ICU argument, and plural objects still work: the module picks the form, and the formatter formats it. Polish `items` reads "5 produktów" as a plural object and as the ICU message above.
- **The locale is the request's.** A message that `de-AT` reads from the German catalog is formatted for `de-AT`, so a number argument with a currency style reads `$ 97,95`. A message that fell back to the default locale is formatted for the default locale, whose grammar it's written in.
- **Keys stay checked, plural counts don't.** The compiler requires a numeric `count` for a plural object, but an ICU plural is a string to it, so pass its argument yourself.

#### Try it

Start the application and place an order with Polish in `Accept-Language`:

```bash
$ curl -X POST http://localhost:3000/orders \
    -H 'Content-Type: application/json' \
    -H 'Accept-Language: pl-PL,pl;q=0.9,en;q=0.8' \
    -d '{"items":[{"productId":"salmon-kibble-2kg","quantity":2},{"productId":"sisal-scratching-post","quantity":3}]}'
{"id":1002,"message":"Dziękujemy! Zamówienie #1002 zostało przyjęte: 5 produktów."}
```

The `lang` query parameter wins over any header:

```bash
$ curl -X POST 'http://localhost:3000/orders?lang=de' \
    -H 'Content-Type: application/json' \
    -d '{"items":[{"productId":"feather-wand","quantity":1}]}'
{"id":1003,"message":"Vielen Dank! Bestellung #1003 ist bestätigt: 1 Produkt."}
```

Read the summary of order #1001 as an Austrian customer:

```bash
$ curl http://localhost:3000/orders/1001 -H 'Accept-Language: de-AT'
```

```json
{
  "id": 1001,
  "status": "Versandt",
  "items": "5 Produkte",
  "total": "$ 97,95",
  "placedAt": "14. September 2026"
}
```

Ask for an order that doesn't exist, with the `x-lang` header:

```bash
$ curl http://localhost:3000/orders/999 -H 'x-lang: pl'
```

```json
{
  "message": "Nie znaleziono zamówienia #999.",
  "error": "Not Found",
  "statusCode": 404
}
```

Send an invalid order:

```bash
$ curl -X POST 'http://localhost:3000/orders?lang=pl' \
    -H 'Content-Type: application/json' \
    -d '{"items":[{"productId":"salmon-kibble-2kg","quantity":0},{"productId":42,"quantity":12}]}'
```

```json
{
  "message": [
    "items.0.quantity musi wynosić co najmniej 1",
    "items.1.productId musi być tekstem",
    "items.1.quantity: można zamówić najwyżej 10 sztuk jednego produktu"
  ],
  "error": "Bad Request",
  "statusCode": 400
}
```

Send an invalid shipping address:

```bash
$ curl -X PUT http://localhost:3000/orders/1001/shipping-address \
    -H 'Content-Type: application/json' \
    -H 'Accept-Language: de' \
    -d '{"recipient":"A","city":"Wien","postalCode":"1010","country":"AT"}'
```

```json
{
  "message": [
    "recipient muss mindestens 2 Zeichen lang sein",
    "postalCode hat ein ungültiges Format",
    "Wir liefern nur in die USA, nach Polen und nach Deutschland."
  ],
  "error": "Bad Request",
  "statusCode": 400
}
```

Finally, query the order over GraphQL:

```bash
$ curl -X POST 'http://localhost:3000/graphql?lang=pl' \
    -H 'Content-Type: application/json' \
    -d '{"query":"{ order(id: 1001) { id status statusLabel items } }"}'
```

```json
{
  "data": {
    "order": {
      "id": 1001,
      "status": "shipped",
      "statusLabel": "Wysłane",
      "items": "5 produktów"
    }
  }
}
```

> info **Hint** `Intl` separates the amount and the currency with a no-break space (U+00A0) in `97,95 USD`, `97,95 $` and `$ 97,95`. The responses above show a regular space. Keep that in mind when you compare formatted strings in tests.

#### Testing

An [end-to-end test](/fundamentals/testing#end-to-end-testing) sets the language the same way a client does:

```typescript

import type { INestApplication } from '@nestjs/common';
import { Test } from '@nestjs/testing';
import request from 'supertest';
import { AppModule } from '../src/app.module.js';

describe('Orders (e2e)', () => {
  let app: INestApplication;

  beforeAll(async () => {
    const moduleRef = await Test.createTestingModule({
      imports: [AppModule],
    }).compile();
    app = moduleRef.createNestApplication();
    await app.listen(0, '127.0.0.1');
  });

  afterAll(() => app.close());

  it('confirms an order in the language of the request', async () => {
    const res = await request(app.getHttpServer())
      .post('/orders')
      .set('Accept-Language', 'pl-PL,pl;q=0.9')
      .send({ items: [{ productId: 'salmon-kibble-2kg', quantity: 5 }] })
      .expect(201);

    expect(res.body.message).toBe(
      'Dziękujemy! Zamówienie #1002 zostało przyjęte: 5 produktów.',
    );
  });

  it('localizes errors', async () => {
    const res = await request(app.getHttpServer())
      .get('/orders/999?lang=de')
      .expect(404);

    expect(res.body.message).toBe('Bestellung #999 wurde nicht gefunden.');
  });
});
```

Outside a request, `I18nService` uses the default locale. To test a service in another language without HTTP, run the call inside `I18nContext.run()`, which makes the locale current as a request would:

```typescript

import { I18nContext } from '@nestjs/i18n';
import { Test } from '@nestjs/testing';
import { AppModule } from '../src/app.module.js';
import { OrdersService } from '../src/orders/orders.service.js';

describe('OrdersService', () => {
  let ordersService: OrdersService;
  let i18nContext: I18nContext;

  beforeAll(async () => {
    const moduleRef = await Test.createTestingModule({
      imports: [AppModule],
    }).compile();
    ordersService = moduleRef.get(OrdersService);
    i18nContext = moduleRef.get(I18nContext);
  });

  it.each([
    [1, '1 produkt'],
    [2, '2 produkty'],
    [5, '5 produktów'],
    [22, '22 produkty'],
  ])('describes %i item(s) in Polish', (quantity, expected) => {
    const order = {
      ...ordersService.findOne(1001),
      items: [{ productId: 'salmon-kibble-2kg', quantity }],
    };
    expect(i18nContext.run('pl', () => ordersService.describeItems(order))).toBe(expected);
  });

  it('falls back to the default locale outside a request', () => {
    const order = ordersService.findOne(1001);
    expect(ordersService.describeItems(order)).toBe('5 items');
  });
});
```

To run the application with other catalogs, for example with a translation removed to test the missing-key policy, replace the loader on the testing module: `overrideProvider(I18nLoader).useValue(new InMemoryI18nLoader(catalogs))`.

The `'throw'` policy catches missing keys on the paths your tests exercise. A catalog test catches the rest: every locale must have the same keys and placeholders as English, and every plural message must have the forms its language needs. `Intl.PluralRules` knows which forms those are:

```typescript

import { JsonI18nLoader, type I18nCatalog } from '@nestjs/i18n';
import { join } from 'node:path';

type Message = string | Record<string, string>;

/** Flattens a catalog to [key, message] pairs; plural forms count as one message. */
function messages(tree: I18nCatalog, prefix = ''): [string, Message][] {
  return Object.entries(tree).flatMap(([name, value]): [string, Message][] =>
    typeof value === 'string' || 'other' in value
      ? [[prefix + name, value as Message]]
      : messages(value, `${prefix}${name}.`),
  );
}

const placeholders = (message: Message) =>
  [...new Set(JSON.stringify(message).match(/\{\w+\}/g))].sort();

const loader = new JsonI18nLoader({ path: join(import.meta.dirname, '../src/i18n') });
const catalogs = await loader.load();
const english = new Map(messages(catalogs.en));

describe.each(Object.keys(catalogs))('the %s catalog', (lang) => {
  const translated = new Map(messages(catalogs[lang]));

  it('has the same keys and placeholders as the English one', () => {
    expect([...translated.keys()].sort()).toEqual([...english.keys()].sort());
    for (const [key, message] of translated) {
      expect(placeholders(message), key).toEqual(placeholders(english.get(key)!));
    }
  });

  it('has every plural form the language needs', () => {
    const forms = new Intl.PluralRules(lang).resolvedOptions().pluralCategories;
    for (const [key, message] of translated) {
      if (typeof message === 'object') {
        expect(Object.keys(message), key).toEqual(expect.arrayContaining(forms));
      }
    }
  });
});
```

Delete the `many` form from the Polish `items` message, and the second test fails with the list of forms Polish needs.

#### Production checklist

- **Ship the catalogs.** Check that `dist/i18n` exists after `nest build`. The loader reads the directory at startup, and a missing directory fails the bootstrap.
- **Use `'fallback'` in production and `'throw'` everywhere else**, and run the catalog test in CI. Watch production logs for fallback warnings.
- **Turn off `watch` in production.** Hot reload is a development tool.
- **Pin `supportedLocales`.** Otherwise every directory under `i18n` becomes a locale, including one that appears while the app is running with `watch` enabled.
- **Add formatting regions to `fallbacks`.** A region resolves to its base language unless it's listed, and then it's formatted like the base language too.
- **Set the locale outside requests.** Cron jobs, queue consumers and outgoing emails have no request, so `I18nService` falls back to the default locale there, and the `t()` function returns the key. Save each customer's preferred locale, and run the work inside `I18nContext.run()`.
- **Mind caches.** Responses to the same URL differ by `Accept-Language` and `x-lang`. The middleware lists both in `Vary`; check that your cache or CDN honors it. The `lang` query parameter is part of the URL, so it needs nothing extra.
- **Keep GraphQL under the global prefix.** With `setGlobalPrefix()`, the locale middleware only covers routes under the prefix. Give `GraphQLModule` `useGlobalPrefix: true`, or guards on resolvers stop seeing the locale (see [Localize GraphQL fields](/application/i18n#localize-graphql-fields)).
- **Escape messages that end up in HTML.** Arguments such as a customer's name are inserted as they are. That's right for JSON responses, but an HTML email must escape the message like any other text.
- **Keep numbers and dates in the locale's hands, and currency and time zone out of them.** See [Format prices and dates](/application/i18n#format-prices-and-dates).

#### Reference

##### Module options

`I18nModule.forRoot()` takes these options. `forRootAsync()` takes the same values from its factory (`useFactory` with `inject`, or a `useClass` or `useExisting` class that implements `I18nOptionsFactory`), while `imports`, `isGlobal` and loader, resolver or formatter classes stay at the top level. See [Options from configuration](/application/i18n#options-from-configuration). The resolved options are injectable with the `I18N_MODULE_OPTIONS` token.

| Option | Default | Description |
| --- | --- | --- |
| `loader` | required | Where catalogs come from: an `I18nLoader` instance such as `JsonI18nLoader`, or a loader class Nest instantiates. See [Add catalogs and register the module](/application/i18n#add-catalogs-and-register-the-module). |
| `resolvers` | `[new AcceptLanguageLocaleResolver()]` | Locale resolvers, tried in order: instances, or classes Nest instantiates. |
| `formatter` | the built-in syntax | An `I18nMessageFormatter` instance, or a class Nest instantiates, that turns messages into text. See [Use ICU message syntax](/application/i18n#use-icu-message-syntax). |
| `defaultLocale` | `'en'` | Used when no resolver finds a supported locale, and outside requests. |
| `supportedLocales` | the loaded locales | The locales a request can get, as BCP 47 tags. |
| `fallbacks` | none | An object that maps a locale to the one its messages come from, such as `'de-AT': 'de'`. Keys count as supported locales. |
| `missingKey` | `'fallback'` | What a missing key returns: `'fallback'`, `'key'`, `'empty'`, `'throw'`, or a function that receives the key and the locale. See [Handle missing keys and reload catalogs](/application/i18n#handle-missing-keys-and-reload-catalogs). |
| `logMissingKeys` | `'once'` | Warn about missing keys and default-locale fallbacks `'once'` per key, `'always'`, or `'never'`. |
| `responseHeaders` | `true` | Set `Content-Language` and `Vary` on HTTP responses. |
| `imports` | none | Modules whose exported providers loader, resolver and formatter classes inject. |
| `isGlobal` | `true` | Register the module globally. |

`JsonI18nLoader` takes `path`, the directory with one subdirectory per locale, and `watch` (default `false`), which reloads catalogs when a file changes. `InMemoryI18nLoader` takes the catalogs as an object keyed by locale.

##### Locale resolvers

| Resolver | Reads | Default name |
| --- | --- | --- |
| `QueryLocaleResolver` | a query parameter | `lang` |
| `HeaderLocaleResolver` | a request header | `x-lang` |
| `AcceptLanguageLocaleResolver` | `Accept-Language`, ordered by `q` values | none |
| `CookieLocaleResolver` | a cookie in the `Cookie` header | `lang` |

A custom resolver extends `LocaleResolver` and implements `resolve()`, which receives the request `headers` and `query`, and returns a candidate, a list of candidates, or nothing. It lists the headers it reads in `varyHeaders`. With `afterGuards` set, it runs after guards, keeping its rank, and receives the `executionContext`, as every resolver does for microservices, WebSocket gateways and GraphQL subscriptions. See [Use the customer's saved language](/application/i18n#use-the-customers-saved-language).

##### Translation API

- `I18nService.t(key, options)`: translates for the current locale. `options` takes `args`, the placeholder values (a numeric `count` selects the plural form), and `locale`, an explicit locale. See [Translate order confirmations](/application/i18n#translate-order-confirmations). `I18nService<OtherTranslations>` checks keys against another catalog shape.
- `I18nService.translate(key, options)`: the same with an unchecked key, for keys built at runtime.
- `I18nService.exists(key, locale)`: whether a key has a message.
- `I18nService.formatNumber(value, options)` and `formatDate(value, options)`: `Intl` formatting in the current locale, with the usual `Intl` options plus `locale`. See [Format prices and dates](/application/i18n#format-prices-and-dates).
- `I18nService.matchLocale(candidate)`: maps a locale, such as one saved on a profile, to a supported one, or `undefined`.
- `I18nService.defaultLocale` and `supportedLocales`: the configured locales, for a language picker.
- `I18nContext.locale` and `I18nContext.run(locale, fn)`: read the current locale, or make one current for code outside a request.
- `I18nContext.setLocale(locale)`: changes the locale for the rest of the request and returns the matched locale, or `undefined` for an unsupported one, which changes nothing. It throws outside a request, and on microservices, WebSocket gateways and GraphQL subscriptions in guards, which run before the locale is resolved.
- `@CurrentLocale()`: the current locale as a handler parameter.
- `t(key, options)`: `I18nService.t()` for code without dependency injection. Outside a request, it returns the key. See [Localize exceptions](/application/i18n#localize-exceptions).

##### Validation

| Export | Description |
| --- | --- |
| `I18nValidationPipe` | Takes the `ValidationPipe` options, plus `namespace` (default `'validation'`) for constraint messages. See [Localize validation messages](/application/i18n#localize-validation-messages). |
| `I18nStandardSchemaValidationPipe` | Takes the `StandardSchemaValidationPipe` options, plus `namespace` and `issueKeys`, a function that returns extra candidate keys for an issue. See [With Zod](/application/i18n#with-zod). |
| `i18nValidationMessage(key, args)` | Points a `class-validator` constraint at a key. |
| `i18nIssueMessage(key, args)` | Points a Standard Schema issue at a key. |
| `translateValidationErrors(errors, options)` | Translates `class-validator` errors, for pipes of your own. |
| `translateStandardSchemaIssues(issues, options)` | Translates Standard Schema issues, for pipes of your own. |

##### Errors

| Error | When |
| --- | --- |
| `I18nMissingKeyError` | A key is missing under `missingKey: 'throw'`. It carries `key` and `locale`, and isn't an `HttpException`, so the request fails with a 500. |
