NestJS Logo

Internationalization

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:


$ 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.


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 explains.

src/i18n/en/orders.json
JS TS

{
  "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 in nest-cli.json:


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

Now register I18nModule in the root module:

app.module.ts
JS TS

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: 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 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:

i18n/translations.ts
JS TS

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():

orders/orders.service.ts
JS TS

@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:

orders/orders.controller.ts
JS TS

@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:

src/i18n/pl/orders.json
JS TS

{
  "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.

ItemsEnglishPolishGerman
11 item1 produkt1 Produkt
22 items2 produkty2 Produkte
55 items5 produktów5 Produkte
2222 items22 produkty22 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: {{id}} prints {id}. For select, ordinals and exact matches, use ICU message syntax.

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 shows.

Localize exceptions#

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

orders/orders.service.ts
JS TS

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:


{
  "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:

orders/orders.controller.ts
JS TS

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.

If your API shapes errors with its own exception filter, for example as RFC 9457 problem details, the filter reads the translated message like any other:

common/problem-details.filter.ts
JS TS

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:


{
  "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. I18nValidationPipe is a drop-in replacement for Nest's ValidationPipe: it accepts the same options and only translates the messages. Bind it where ValidationPipe was, here as an APP_PIPE provider of AppModule:

app.module.ts
JS TS

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:

orders/dto/create-order.dto.ts
JS TS

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), 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":

src/i18n/pl/validation.json
JS TS

{
  "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:

orders/dto/shipping-address.dto.ts
JS TS

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';
}
orders/orders.controller.ts
JS TS

@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 section shows the response.

With Zod

If your application validates with Zod, Valibot, ArkType or another Standard Schema library, bind I18nStandardSchemaValidationPipe instead of I18nValidationPipe. It's the drop-in replacement for Nest's StandardSchemaValidationPipe, and an application binds one of the two pipes, not both:

app.module.ts
JS TS

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:

orders/orders.schemas.ts
JS TS

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:

orders/orders.controller.ts
JS TS

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:

src/i18n/pl/validation.json
JS TS

{
  "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 section gets the same three German messages as with the class, and the invalid order reads:


{
  "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 schema registered in AppModule:

app.module.ts
JS TS

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

The object type exposes raw order data:

orders/order.model.ts
JS TS

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:

orders/orders.resolver.ts
JS TS

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 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 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: 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:

orders/orders.service.ts
JS TS

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:

LocaletotalplacedAt
en$97.95September 14, 2026
pl97,95 USD14 września 2026
de97,95 $14. September 2026
de-AT$ 97,9514. 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. 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, 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:

customers/customer-locale.resolver.ts
JS TS

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:

app.module.ts
JS TS

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.

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:

customers/customers.controller.ts
JS TS

@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:

src/i18n/de/customers.json
JS TS

{
  "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:

common/message-locale.resolver.ts
JS TS

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):

orders/order-updates.gateway.ts
JS TS

@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:

app.module.ts
JS TS

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.

Hint Fallback works per key. When messages are composed as in 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 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 with a validation schema, and register the module with forRootAsync(). Its factory receives the injected ConfigService and returns the options:

app.module.ts
JS TS

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:

i18n/i18n-config.service.ts
JS TS

@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',
      // ...
    };
  }
}
app.module.ts
JS TS

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:

i18n/remote-i18n.loader.ts
JS TS

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:


{
  "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 library, which your application installs:

i18n/icu-message.formatter.ts
JS TS

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:

app.module.ts
JS TS

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:


$ 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:


$ 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:


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

{
  "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:


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

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

Send an invalid order:


$ 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}]}'

{
  "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:


$ 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"}'

{
  "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:


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

{
  "data": {
    "order": {
      "id": 1001,
      "status": "shipped",
      "statusLabel": "Wysłane",
      "items": "5 produktów"
    }
  }
}
HintIntl 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 sets the language the same way a client does:

test/orders.e2e.spec.ts
JS TS

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:

test/orders.service.spec.ts
JS TS

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:

test/catalogs.spec.ts
JS TS

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 GraphQLModuleuseGlobalPrefix: true, or guards on resolvers stop seeing the locale (see 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.

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. The resolved options are injectable with the I18N_MODULE_OPTIONS token.

OptionDefaultDescription
loaderrequiredWhere catalogs come from: an I18nLoader instance such as JsonI18nLoader, or a loader class Nest instantiates. See Add catalogs and register the module.
resolvers[new AcceptLanguageLocaleResolver()]Locale resolvers, tried in order: instances, or classes Nest instantiates.
formatterthe built-in syntaxAn I18nMessageFormatter instance, or a class Nest instantiates, that turns messages into text. See Use ICU message syntax.
defaultLocale'en'Used when no resolver finds a supported locale, and outside requests.
supportedLocalesthe loaded localesThe locales a request can get, as BCP 47 tags.
fallbacksnoneAn 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.
logMissingKeys'once'Warn about missing keys and default-locale fallbacks 'once' per key, 'always', or 'never'.
responseHeaderstrueSet Content-Language and Vary on HTTP responses.
importsnoneModules whose exported providers loader, resolver and formatter classes inject.
isGlobaltrueRegister 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
ResolverReadsDefault name
QueryLocaleResolvera query parameterlang
HeaderLocaleResolvera request headerx-lang
AcceptLanguageLocaleResolverAccept-Language, ordered by q valuesnone
CookieLocaleResolvera cookie in the Cookie headerlang

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.

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. 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.
  • 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.
Validation
ExportDescription
I18nValidationPipeTakes the ValidationPipe options, plus namespace (default 'validation') for constraint messages. See Localize validation messages.
I18nStandardSchemaValidationPipeTakes the StandardSchemaValidationPipe options, plus namespace and issueKeys, a function that returns extra candidate keys for an issue. See 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
ErrorWhen
I18nMissingKeyErrorA key is missing under missingKey: 'throw'. It carries key and locale, and isn't an HttpException, so the request fails with a 500.
Edit on GitHub

Support us

Nest is an MIT-licensed open source project. It can grow thanks to the support of these awesome people. If you'd like to join them, please read more here.

Principal Sponsors

SerpApi LogoTrilon LogoMojam Logo

Sponsors / Partners

Become a sponsor