Software localization

A Guide to NestJS Localization

We localize a Nest app with nestjs-i18n, working through controller, service, and database localization.
Illustration of the NestJS logo surrounded by various development-related icons on a purple background, symbolizing the integration of advanced localization features and development tools.

A fast and loose framework like Express is great for small server-side apps, but as projects and teams scale, clean architecture and patterns are needed to keep things SOLID, making it easier to find and change code. Enter the Angular-inspired, architecture-focused NestJS, which sees downloads of 3 million per week. The thoughtful structure of NestJS facilitates our projects’ growth while minimizing technical debt.

While NestJS is a comprehensive framework, it leaves internationalization (i18n) up to the developer. Fortunately, Toon van Strijp created nestjs-i18n, a library that greatly simplifies NestJS localization. nestjs-i18n resolves the current request’s language, manages translation files, and formats messages, all with built-in type safety. In this guide we’ll take nestjs-i18n for a spin, building a REST API with NestJS and localizing it. Let’s dive in.

🔗 Resource » Internationalization (i18n) and localization (l10n) allow us to make our apps available in different languages and regions, often for more profit. If you’re new to i18n and l10n, check out our guide to internationalization.

🔗 Resource » If you’re interested in general Node.js or Express i18n, check out our Guide to Node.js Internationalization (I18n).

Our demo

We’ll build a fictitious jogging blog called Yogger Chicken, implementing it as a REST API with the following endpoints.

GET    /          # Root, redirects to /info
GET    /info      # Lists available endpoints
GET    /today     # Shows a daily quote
POST   /posts     # Creates a new blog post
GET    /posts     # Lists all blog posts
GET    /posts/1   # Shows a blog post with ID 1
PATCH  /posts/1   # Updates blog post with ID 1
Code language: plaintext (plaintext)

Package versions

We’ll use the following NPM packages (versions in parentheses). Don’t worry, we’ll walk through installing them as we go.

  • typescript (5.4) — our programming language
  • @nestjs/cli (10.3) — Nest’s command-line interface, used to spin up new projects and generate code
  • @nestjs/common (10.3) — Nest (NestJS) itself
  • @nestjs/mapped-types (2.0) — allows us to derive a DTO from another DTO
  • sqlite3 (5.1) — database driver for development
  • typeorm (0.3) — our Object-Relational Mapper (ORM) for working with the database in code
  • @nestjs/typeorm (10.0) — first-party module that bridges TypeORM and Nest
  • class-transformer (0.5) — used along with class-validator for decorator-based DTO validation
  • class-validator (0.14) — used for decorator-based DTO validation
  • nestjs-i18n (10.4) — our localization library

Let’s start by installing the Nest CLI from the command line and use it to spin up a new Nest project.

$ npm install -g @nestjs/cli
$ nest new yogger-chicken
Code language: Bash (bash)

The info route

Next, we’ll add an /info route that lists our API endpoints for consumers.

$ nest generate module info
$ nest generate controller info
$ nest generate service info
Code language: Bash (bash)

Let’s add the JSON that /info will serve to our info service.

// src/info/info.service.ts

import { Injectable } from '@nestjs/common';

@Injectable()
export class InfoService {
  getInfo() {
    return {
      about: 'yogger chicken: a headless running blog',
      lastUpdated: new Date().toISOString(),
      routes: [
        {
          verb: 'GET',
          path: '/',
          description: 'Redirects to /info',
        },
        {
          verb: 'GET',
          path: '/info',
          description: 'You are here',
        },
        {
          verb: 'GET',
          path: '/today',
          description: 'Daily quote',
        },
        {
          posts: [
            {
              verb: 'GET',
              path: '/posts',
              description: 'Index of all posts',
            },
            {
              verb: 'GET',
              path: '/posts/1',
              description: 'Post with ID 1',
            },
            {
              verb: 'POST',
              path: '/posts',
              description: 'Create a new post',
            },
            {
              verb: 'PATCH',
              path: '/posts/1',
              description: 'Update post with ID 1',
            },
          ],
        },
      ],
    };
  }
}
Code language: TypeScript (typescript)

The Nest CLI should have wired up the info module and controller when we created them. So if we run our app using npm run start:dev and hit the /info route, we should JSON output like the following.

Our REST client making a GET request to the URL /info with no query parameters. The response code is 200, and the response body contains { "about": "yogger chicken: a headless running blog" }.

Let’s update the root route (/) to redirect to /info.

// src/app.controller.ts

import { Controller, Get, Redirect } from '@nestjs/common';

@Controller()
export class AppController {
  @Get()
  @Redirect('/info', 301)
  getRoot() {}
}
Code language: TypeScript (typescript)

If we hit / now, we should see the same output as we did for /info.

Posts and the database

We want CRUD (Create, Read, Update, Delete) endpoints for our blog posts. To persist post data, let’s use a SQLite development database. TypeORM works well with Nest, so we’ll use it to interface with the database. Let’s install the dependencies.

 $ npm install sqlite3 typeorm @nestjs/typeorm
Code language: Bash (bash)

class-validator works out-of-the-box with Nest’s ValidationPipe, so it’s a good choice for validating our DTO (Data Transfer Objects). Let’s install it.

$ npm install class-validator class-transformer
Code language: Bash (bash)

And to make our lives a bit easier, let’s install Nest’s mapped-types package, which allows us to derive a DTO from another.

$ npm install @nestjs/mapped-types
Code language: Bash (bash)

Now we can configure TypeORM in the root AppModule.

// src/app.module.ts

  import { Module } from '@nestjs/common';
+ import { TypeOrmModule } from '@nestjs/typeorm';
  import { AppController } from './app.controller';
  import { InfoModule } from './info/info.module';
  import { TodayModule } from './today/today.module';

  @Module({
    imports: [
+     TypeOrmModule.forRoot({
+       type: 'sqlite',
+       database: 'db.sqlite',
+       entities: [__dirname + '/**/*.entity.{ts,js}'],
+       synchronize: true, // DO NOT USE IN PRODUCTION
+       logging: true,
+     }),
      InfoModule,
      TodayModule,
    ],
    controllers: [AppController],
    providers: [],
  })
  export class AppModule {}
Code language: Diff (diff)

If we run our app now, we should see an empty db.sqlite file created in the root directory of our project.

OK, onto the posts themselves. Let’s put the Nest CLI to work generating our posts boilerplate.

$ nest generate module posts
$ nest generate service posts
$ nest generate controller posts
Code language: Bash (bash)

We’ll manually add the Post entity in a new file, keeping it simple.

// src/posts/entities/post.entity.ts

import {
  Column,
  Entity,
  PrimaryGeneratedColumn,
} from 'typeorm';

@Entity()
export class Post {
  @PrimaryGeneratedColumn()
  id: number;

  @Column()
  title: string;

  @Column()
  content: string;
}
Code language: TypeScript (typescript)

This Post entity will be used in our PostsService via TypeORM’s repository pattern, which means we should import Post into our PostsModule.

// src/posts/posts.module.ts

  import { Module } from '@nestjs/common';
+ import { TypeOrmModule } from '@nestjs/typeorm';
+ import { Post } from './entities/post.entity';
  import { PostsController } from './posts.controller';
  import { PostsService } from './posts.service';

  @Module({
+   imports: [TypeOrmModule.forFeature([Post])],
    providers: [PostsService],
    controllers: [PostsController],
  })
  export class PostsModule {}
Code language: Diff (diff)

Let’s also add our create and update the post DTO.

// src/posts/dto/create-post.dto.ts

import {
  IsNotEmpty,
  IsString,
  Length,
} from 'class-validator';

export class CreatePostDto {
  @IsString()
  @IsNotEmpty()
  @Length(1, 255)
  title: string;

  @IsString()
  @IsNotEmpty()
  content: string;
}
Code language: TypeScript (typescript)
// src/posts/dto/update-post.dto.ts

import { PartialType } from '@nestjs/mapped-types';
import { CreatePostDto } from './create-post.dto';

export class UpdatePostDto extends PartialType(
  CreatePostDto,
) {}
Code language: TypeScript (typescript)

Weaving all these parts together is the all-important PostsService.

// src/posts/posts.service.ts

import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { CreatePostDto } from './dto/create-post.dto';
import { UpdatePostDto } from './dto/update-post.dto';
import { Post } from './entities/post.entity';

export interface PostSummary {
  id: number;
  title: string;
}

@Injectable()
export class PostsService {
  constructor(
    @InjectRepository(Post)
    private postRepo: Repository<Post>,
  ) {}

  async findAll(): Promise<PostSummary[]> {
    return await this.postRepo.find({
      select: ['id', 'title'],
    });
  }

  async findOne(id: number): Promise<Post | null> {
    return await this.postRepo.findOneBy({ id });
  }

  async create(
    createPostDto: CreatePostDto,
  ): Promise<Post> {
    const newPost = this.postRepo.create({
      ...createPostDto,
    });

    return this.postRepo.save(newPost);
  }

  async update(
    id: number,
    updatePostDto: UpdatePostDto,
  ): Promise<Post | null> {
    const post = await this.findOne(id);

    if (!post) {
      return null;
    }

    if (post) {
      post.title = updatePostDto.title ?? post.title;
      post.content = updatePostDto.content ?? post.content;
    }

    return await this.postRepo.save(post);
  }

  async remove(id: number): Promise<boolean> {
    const post = await this.findOne(id);

    if (!post) {
      return false;
    }

    await this.postRepo.remove(post);

    return true;
  }
}
Code language: TypeScript (typescript)

🗒️ Note » We have strictNullChecks: true in our ./tsconfig.json file.

Our controller exposes the posts service to the outside world.

// src/posts/posts.controller.ts

import {
  Body,
  Controller,
  Delete,
  Get,
  NotFoundException,
  Param,
  Patch,
  Post,
  UsePipes,
  ValidationPipe,
} from '@nestjs/common';
import { CreatePostDto } from './dto/create-post.dto';
import { UpdatePostDto } from './dto/update-post.dto';
import { Post as PostEntity } from './entities/post.entity';
import { PostSummary, PostsService } from './posts.service';

@Controller('posts')
export class PostsController {
  constructor(
    private readonly postsService: PostsService,
  ) {}

  @Get()
  async findAll(): Promise<PostSummary[]> {
    return this.postsService.findAll();
  }

  @Get(':id')
  async findOne(
    @Param('id') id: string,
  ): Promise<PostEntity> {
    const post = await this.postsService.findOne(+id);

    if (!post) {
      this.throwNotFound(id);
    }

    return post;
  }

  @Post()
  @UsePipes(new ValidationPipe({ whitelist: true }))
  async create(
    @Body() createPostDto: CreatePostDto,
  ): Promise<PostEntity> {
    return this.postsService.create(createPostDto);
  }

  @Patch(':id')
  @UsePipes(new ValidationPipe({ whitelist: true }))
  async update(
    @Param('id') id: string,
    @Body() updatePostDto: UpdatePostDto,
  ): Promise<PostEntity> {
    const post = await this.postsService.update(
      +id,
      updatePostDto,
    );

    if (!post) {
      this.throwNotFound(id);
    }

    return post;
  }

  @Delete(':id')
  async remove(
    @Param('id') id: string,
  ): Promise<{ message: string }> {
    const ok = await this.postsService.remove(+id);

    if (!ok) {
      this.throwNotFound(id);
    }

    return { message: 'Post deleted' };
  }

  private throwNotFound(id: string): never {
    throw new NotFoundException(
      `Post with ID ${id} not found`,
    );
  }
}
Code language: TypeScript (typescript)

🗒️ Note » We’re skipping authentication and authorization in this tutorial for brevity.

We can now create and update posts to our heart’s content.

Our REST client making a POST request to the URL /posts with form-encoded parameters title="Running in place" and content="I love my treadmill, but I will admit...". The response code is 201 Created, and the response body contains { "id": 5, "title": "Running in place", "content": "I love my treadmill, but I will admit..." }.

Our REST client making a GET request to the URL /posts with no query parameters. The response code is 200, and the response body contains a JSON object with a list of posts, displaying their ids and titles.

🔗 Resource » You can get all the starter demo code from our GitHub repo’s start branch.

How do I localize my Nest app?

OK, it’s time for the main course. This is our recipe for localizing a Nest app with nestjs-i18n:

1. Install and set up nestjs-i18n.
2. Move our hard-coded strings to translation files.
3. Load translation files using one of nestjs-i18n’s loaders.
4. Inject the i18n context and service into controllers and services, respectively, and use them to fetch strings from translation files.
5. Determine the active locale using nestjs-i18n’s resolvers.
6. Add type safety to our translation keys.
7. Handle dynamic values and localized plurals in our translations.
8. Localize the database data.
9. Localize validation messages.

We’ll go through these step-by-step.

A note on locales

A locale defines a language, a region, and sometimes more. Locales typically use IETF BCP 47 language tags, like en for English, fr for French, and es for Spanish. A region can be added with the ISO Alpha-2 code (e.g., BH for Bahrain, CN for China, US for the United States). So a locale with a region might look like en-US for English as used in the United States or zh-CN for Chinese as used in China.

🔗 Explore more language tags on Wikipedia and find country codes through the ISO’s search tool.

How do I set up nestjs-i18n?

First things first, let’s install the package.

$ npm install nestjs-i18n
Code language: Bash (bash)

Next, let’s create a src/locales directory to house our translations. We’ll work with English (en) and Arabic (ar) in this tutorial. Feel free to add any locales you want here.

// src/locales/en/common.json

{
  "about": "yogger chicken: a headless blog about"
}
Code language: JSON / JSON with Comments (json)
// src/locales/ar/common.json

{
  "about": "دجاج يوجر: مدونة جري بلا رأس"
}
Code language: JSON / JSON with Comments (json)

We need to add the global I18nModule from nestjs-i18n to our AppModule.

// src/app.module.ts

  import { Module } from '@nestjs/common';
  import { TypeOrmModule } from '@nestjs/typeorm';
+ import { I18nModule, QueryResolver } from 'nestjs-i18n';
+ import * as path from 'path';
  import { AppController } from './app.controller';
  import { InfoModule } from './info/info.module';
  import { PostsModule } from './posts/posts.module';
  import { TodayModule } from './today/today.module';

  @Module({
    imports: [
      TypeOrmModule.forRoot({
        type: 'sqlite',
        database: 'db.sqlite',
        entities: [__dirname + '/**/*.entity.{ts,js}'],
        synchronize: true,
        logging: true,
      }),
+     I18nModule.forRoot({
+       fallbackLanguage: 'en',
+       loaderOptions: {
+         path: path.join(__dirname, '/locales/'),
+         watch: true,
+       },
+       resolvers: [new QueryResolver(['lang'])],
+     }),
      InfoModule,
      PostsModule,
      TodayModule,
    ],
    controllers: [AppController],
    providers: [],
  })
  export class AppModule {}
Code language: Diff (diff)

nestjs-i18n uses a loader to read our translation message files, which we configure in loaderOptions. The watch option ensures that translations are reloaded into the app when translation files change.

Our app needs to have a single active locale for a request. In our app, this will be English (en) or Arabic (ar). nestjs-i18n uses one or more resolvers to determine the current request’s locale. We’re using the QueryResolver, which will look at a query param called lang to determine the active locale. For example, if our URL is http://localhost:3000?lang=ar, the active locale will resolve to Arabic (ar).

The fallbackLanguage is used when we can’t otherwise resolve the active locale for a request.

🗒️ Note » We’ll look at resolvers and loaders more closely in later sections.

The new locales directory housing our translation files won’t be automatically copied to the dist directory during builds, which will cause errors. We can fix this by explicitly telling the Nest CLI to copy the directory in the nest-cli.json config file.

// nest-cli.json

{
  "$schema": "https://json.schemastore.org/nest-cli",
  "collection": "@nestjs/schematics",
  "sourceRoot": "src",
  "compilerOptions": {
    "deleteOutDir": true,
+   "assets": [
+     { "include": "locales/**/*", "watchAssets": true }
+   ]
  }
}
Code language: Diff (diff)

OK, if we run our app now we should see server logs in our terminal indicating that nestjs-i18n is watching our locales directory for changes.

NEST server log entries showing [I18nService] output. One entry reads "Checking translation changes" and the other reads "No changes detected".

Let’s use the library to localize something, shall we? We’ll do so in the following section, where we’ll look at injecting the I18nContext and I18nService into our controllers and services.

🗒️ Note » If you want to use Nest’s configuration service to set these config options using .env files, you can do so with I18nModule.forRootAsync. See the nestjs-i18n docs Module setup section for more.

🔗 Resource » Check out the I18nOptions section of the docs for a complete listing of config options.

How do I localize controllers and services?

Let’s utilize the translations we added in the previous section to create a new route, /info/about, which will return the localized name of our app. We’ll need to inject the I18nContext object into our route handler method and decorate it with @I18n.

// src/info/info.controller.ts

  import { Controller, Get } from '@nestjs/common';
+ import { I18n, I18nContext } from 'nestjs-i18n';
  import { InfoService } from './info.service';

  @Controller('info')
  export class InfoController {
    constructor(private readonly infoService: InfoService) {}

    @Get()
    getInfo() {
      return this.infoService.getInfo();
    }

+   @Get('about')
+   getAbout(@I18n() i18n: I18nContext) {
+     return i18n.t('common.about');
+   }
  }
Code language: Diff (diff)

Notice the t() method exposed by I18nContext: It takes a translation key in the file.key format and returns the corresponding translation from the active locale’s file.

.
└── src
    └── locales
        ├── en
        │   └── common<strong>.</strong>json
        │       └── "about": "yogger chicken: a headless running blog"
        └── ar
            └── common.json
                └── "about": "دجاج يوجر: مدونة جري بلا رأس"
Code language: plaintext (plaintext)

Given the above translation structure,i18n.t('common.about') will map to the common.json file’s about translation for the active locale. Let’s see this in action by hitting the /info/about route.

Our REST client making a GET request to the URL /info/about with no query parameters. The response code is 200, and the response body contains "yogger chicken: a headless running blog".

Since we didn’t specify a locale in our request, nestjs-i18n will use the configured fallbackLanguage, which is English (en).

What if we want to set the locale in our request explicitly? Recall that we configured nestjs-i18n to use a QueryResolver, which looks for a lang query param to determine the active locale. All we need to do is provide the /?lang={locale} param when we make a request.

Our REST client making a GET request to the URL /info/about with a query parameter locale=ar. The response code is 200, and the response body contains "دجاج يوجر: مدونة جري بلا رأس".

Hitting /info/about?lang=ar sets the active locale to Arabic (ar).

That’s the basic controller translation flow with nestjs-i18n. Of course, sometimes we need to translate strings inside our services. This can be accomplished by injecting the I18nService into our own.

// src/info/info.service.ts

  import { Injectable } from '@nestjs/common';
+ import { I18nContext, I18nService } from 'nestjs-i18n';

  @Injectable()
  export class InfoService {
+   constructor(private readonly i18n: I18nService) {}

    getInfo() {
      return {
-       about: 'yogger chicken: a headless running blog',
+       about: this.i18n.translate('common.about', {
+         lang: I18nContext.current().lang,
+       }),
        lastUpdated: new Date().toISOString(),
        routes: [
          // ...
        ],
      };
    }
  }
Code language: Diff (diff)

Unlike the I18nContext object, the I18nService instance doesn’t know about the active locale; we need to explicitly provide it with a lang option via I18nContext.current().

🗒️ Note » We can call I18nContext.current().translate(key) instead of i18n.t(key).

A wrapper i18n service

It’s cumbersome to provide an I18nContext whenever we want to localize a controller or service. A simple wrapper service can reduce this friction. Let’s create one quickly.

$ nest generate module yc-i18n
$ nest generate service yc-i18n
Code language: Bash (bash)

We’re using Yc as a namespace here. In the new service, we can wrap the I18nService.translate() function with our own t().

 // src/yc-i18n/yc-i18n.service.ts

import { Injectable } from '@nestjs/common';
import { I18nContext, I18nService } from 'nestjs-i18n';

@Injectable()
export class YcI18nService {
  constructor(private readonly i18n: I18nService) {}

  t(key: string, options?: Record<string, any>) {
    const lang = I18nContext.current().lang;
    return this.i18n.translate(key, { lang, ...options });
  }
}
Code language: TypeScript (typescript)

Let’s make sure the YcI18nModule exports its service and is made global.

// src/yc-i18n/yc-i18n.module.ts

import { Global, Module } from '@nestjs/common';
import { YcI18nService } from './yc-i18n.service';

// Make the module global so that we don't have
// to import it into every other module that needs it.
@Global()
@Module({
  providers: [YcI18nService],
  exports: [YcI18nService], // export the service
})
export class YcI18nModule {}

Code language: TypeScript (typescript)

Now we can conveniently use constructor injection to access our YcI18nService.

// src/info/info.service.ts

  import { Injectable } from '@nestjs/common';
- import { I18nContext, I18nService } from 'nestjs-i18n';
+ import { YcI18nService } from 'src/yc-i18n/yc-i18n.service';

  @Injectable()
  export class InfoService {
-   constructor(private readonly i18n: I18nService) {}
+   constructor(private readonly i18n: YcI18nService) {}

    getInfo() {
      return {
-       about: this.i18n.translate('common.about', {
-         lang: I18nContext.current().lang,
-       }),
+       about: this.i18n.t('common.about'),
        lastUpdated: new Date().toISOString(),
        routes: [
          // ...
        ],
      };
    }
  }
Code language: Diff (diff)

We can do the same thing in our controllers, avoiding method injection and decoration.

// src/info/info.controller.ts

  import { Controller, Get } from '@nestjs/common';
- import { I18n, I18nContext } from 'nestjs-i18n';
+ import { YcI18nService } from 'src/yc-i18n/yc-i18n.service';
  import { InfoService } from './info.service';

  @Controller('info')
  export class InfoController {
   constructor(
     private readonly infoService: InfoService,
+    private readonly i18n: YcI18nService,
   ) {}

    @Get()
    getInfo() {
      return this.infoService.getInfo();
    }

    @Get('about')
-   getAbout(@I18n() i18n: I18nContext) {
+   getAbout() {
-     return i18n.t('common.about');
+     return this.i18n.t('common.about');
    }
  }
Code language: Diff (diff)

This refactor reduces the surface area we need to touch when accessing the i18n service.

How do I load translation files?

nestjs-i18n comes with two built-in loaders, one for JSON files (the default) and one for YAML. We’ll prefer the JSON loader in this article. Switching to the YAML loader is easy, however.

// src/app.module.ts

// ...
import {
  AcceptLanguageResolver,
  HeaderResolver,
  I18nModule,
+ I18nYamlLoader,
  QueryResolver,
} from 'nestjs-i18n';
import * as path from 'path';
// ...

@Module({
  imports: [
    // ...
    I18nModule.forRoot({
      fallbackLanguage: 'en',
+     loader: I18nYamlLoader,
      loaderOptions: {
        path: path.join(__dirname, '/locales/'),
        watch: true,
      },
      resolvers: [new QueryResolver(['lang'])],
    }),
    // ...
  ],
  controllers: [AppController],
  providers: [],
})
export class AppModule {}
Code language: Diff (diff)

Of course, this loader assumes our translation files are in YAML format.

# src/locales/en/common.yml

about: 'yogger chicken: a headless running blog'
Code language: YAML (yaml)
# src/locales/ar/common.yml

about: 'دجاج يوجر: مدونة جري بلا رأس'
Code language: YAML (yaml)

Again, we’ll stick to JSON in this article, but the choice is yours.

🗒️ Note » Check out the Loaders docs page for more info, including subfolder loading.

🗒️ Note » It seems that you can create custom loaders for nestjs-i18n. This wasn’t documented at the time of writing, however. So you might have to dig through the nestjs-i18n source code to figure out how to roll your own loaders.

How do I resolve the active locale?

For a given request, we need to set one active locale. The process of determining this locale is called locale resolution, and there are multiple strategies we can use to resolve the active locale:

  • Reading the HTTP Accept-Language header in the response is a common strategy when working with web browsers: The Accept-Language header often contains the locales preferred by the user and set in their browser settings.
  • Reading a custom HTTP header, e.g. x-lang, can be handy since it can allow REST clients to configure the header once and reuse it.
  • Using a query param is a flexible and transparent strategy. We’ve already been doing this with our ?lang param.

nestjs-i18n has several of these resolvers built-in. We’ve already used the QueryResovler. Let’s bring in another two and configure our resolvers into a little cascade.

// src/app.module.ts

import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import {
+ AcceptLanguageResolver,
+ HeaderResolver,
  I18nModule,
  QueryResolver,
} from 'nestjs-i18n';
import * as path from 'path';
// ...

@Module({
  imports: [
    // ...
    I18nModule.forRoot({
      fallbackLanguage: 'en',
      loaderOptions: {
        path: path.join(__dirname, '/locales/'),
        watch: true,
      },
      resolvers: [
        new QueryResolver(['lang']),
+       AcceptLanguageResolver,
+       new HeaderResolver(['x-lang']),
      ],
    }),
    // ...
  ],
  controllers: [AppController],
  providers: [],
})
export class AppModule {}
Code language: Diff (diff)

With this configuration, nestjs-i18n will go through each resolver in order, stopping if it resolves a locale. Let’s test this by hitting http://localhost:3000?lang=ar

Our REST client making a GET request to the URL /query with query parameter lang=ar. The response code is 200, and the response body contains { "about": "دجاج يوجر: مدونة جري بلا رأس"}.

Since we provided a query param of lang=ar, the QueryResolver will catch the param and resolve the active locale to ar. No other resolver will run.

Next test: don’t provide a query param; provide an Accept-Language HTTP header instead.

Our REST client making a GET request to the URL /language with no query parameters. The request headers include an Accept-Language header set to "ar;q=1.0,en;q=0.9". The response code is 200, and the response body contains { "about": "دجاج يوجر: مدونة جري بلا رأس" }.

In this case, the QueryResolver doesn’t find a lang param, so we cascade down to the AcceptLanguageResolver, which does find our header value of ar;q=1.0,en;q=0.9. This value indicates that ar is preferred over en, so the AcceptLanguageResolver resolves to ar.

🔗 Resource » We go into locale detection and the Accept-Language header in our guide, Detecting a User’s Locale in a Web App.

As you can imagine, if we don’t supply a lang query param or an Accept-Language header but do set a custom x-lang HTTP header, our HeaderResolver will kick in.

"Our REST client making a GET request to the URL / with no query parameters. The request headers include a custom header x-lang set to "ar". The response code is 200, and the response body contains { "about": "دجاج يوجر: مدونة جري بلا رأس" }.

If our cascade falls through entirely, nestjs-i18n will use the fallbackLanguage we set in the config (en in our case).

🗒️ Note » nestjs-i18n resolvers use a best-fit algorithm, such that en, en-US, en_GB, etc. will all resolve to en.

🔗 Resource » If you want granular control over fallback behavior, check out the Fallback languages page of the docs.

🔗 Resource » Check out the Resolvers page of the nestjs-i18n docs for more info, including coverage of the CookieResolver, GraphQLWebsocketResolver, and GrpcMetadataResolver.

How do I redirect while keeping the active locale?

We currently have our / route redirecting to /info.

// src/app.controller.ts

import { Controller, Get, Redirect } from '@nestjs/common';

@Controller()
export class AppController {
  @Get()
  @Redirect('/info', 301)
  getRoot() {}
}
Code language: TypeScript (typescript)

While this will keep the original HTTP headers when redirecting, it won’t pass along any query parameters. This means the QueryResolver we configured in the previous section won’t get its lang param when redirecting from / to /info.

We can fix this by copying over the query params manually when redirecting.

// src/app.controller.ts

  import {
    Controller,
    Get,
+   Query,
+   Res,
  } from '@nestjs/common';
+ import { Response } from 'express';

  @Controller()
  export class AppController {
    @Get()
    getRoot(
+     @Query() query: Record<string, any>,
+     @Res() res: Response,
    ) {
+     // copy original query params
+     const queryParams = new URLSearchParams(
+       query,
+     ).toString();
+     return res.redirect(302, `/info?${queryParams}`);
    }
  }
Code language: Diff (diff)

Now if we visit http://localhost:300?lang=ar we’ll correctly see the Arabic output from /info.

Our REST client making a GET request to the URL / with a query parameter locale=ar. The response code is 200, and the response body contains { "about": "دجاج يوجر: مدونة جري بلا رأس" }.

🔗 Resource » Grab all the demo code from our GitHub repo.

How do I make my message keys type-safe?

nestjs-i18n is TypeScript-ready by default, but the keys passed into t() or translate() are not. For bullet-proof (or at least type-safe) translation keys, we must opt into the feature.

nestjs-i18n supports type-safe keys by generating the types on-the-fly when we run our app. Let’s configure this.

// src/app.module.ts

// ...

@Module({
  imports: [
    // ...
    I18nModule.forRoot({
      //...
      resolvers: [
        new QueryResolver(['lang']),
        AcceptLanguageResolver,
        new HeaderResolver(['x-lang']),
      ],
+     typesOutputPath: path.join(
+       __dirname,
+       '../src/generated/i18n.generated.ts',
+     ),
    }),
    // ...
  ],
  controllers: [AppController],
  providers: [],
})
export class AppModule {}
Code language: Diff (diff)

When we add the typesOutputPath to nestjs-i18n’s config options, the library will generate a type file at the given path when we run the app.

Let’s add the generated file to eslint ignores to prevent linting errors.

// .eslintrc.js

module.exports = {
  parser: '@typescript-eslint/parser',
  //...
  env: {
    node: true,
    jest: true,
  },
+ ignores: ['src/generated/i18n.generated.ts'],
  ignorePatterns: ['.eslintrc.js'],
  rules: {
    // ...
  },
};
Code language: Diff (diff)

If we run our app now, we should see a file appear inside src/generated

// src/generated/i18n.generated.ts

/* DO NOT EDIT, file generated by nestjs-i18n */

/* eslint-disable */
/* prettier-ignore */
import { Path } from "nestjs-i18n";
/* prettier-ignore */
export type I18nTranslations = {
    "common": {
        "about": string;
    };
};
/* prettier-ignore */
export type I18nPath = Path<I18nTranslations>;
Code language: TypeScript (typescript)

This file will aggregate all of our translation keys across message files, generating counterpart keys under the I18nTranslations type. We can use I18nTranslations along with I18nPath to add stronger typing to our t() method.

// src/yc-i18n/yc-i18n.service.ts

  import { Injectable } from '@nestjs/common';
  import { I18nContext, I18nService } from 'nestjs-i18n';
+ import {
+   I18nPath,
+   I18nTranslations,
+ } from 'src/generated/i18n.generated';

  @Injectable()
  export class YcI18nService {
    constructor(
-     private readonly i18n: I18nService,
+     private readonly i18n: I18nService<I18nTranslations>,
    ) {}

-   t(key: string, options?: Record<string, any>) {
+   t(key: I18nPath, options?: Record<string, any>) {
      const lang = I18nContext.current().lang;
      return this.i18n.translate(key, { lang, ...options });
    }
  }
Code language: Diff (diff)

With that, when we attempt to call t() with a translation key that doesn’t exist in our message files, we’ll get a TypeScript error in our code editor.

A screenshot of a TypeScript error in an IDE. The error message states: 'Argument of type '"foo.bar"' is not assignable to parameter of type 'PathImpl2<I18nTranslations>'. ts(2345)'. The cursor is on the line containing return this.i18n.t('foo.bar'); with a red squiggly underline under 'foo.bar'.

🗒️ Note » Shut down your app before adding a new translation file and run it afterwards. Otherwise, translations in your new file won’t be added to the generated type file.

🔗 Resource » Read the Type Safety guide in the official docs.

How do I translate basic text?

Let’s quickly repeat the basic translation steps to refresh our memories before tackling advanced translation. First, we add the translations to existing or new translation files.

// src/locales/en/routes.json

{
  "posts": {
    "index": "Index of all posts"
  }
}
Code language: JSON / JSON with Comments (json)
// src/locales/ar/routes.json

{
  "posts": {
    "index": "فهرس جميع المشاركات"
  }
}
Code language: JSON / JSON with Comments (json)

Now we can reference the message using the file.key[.subkey...] format when using the t() function.

i18n.t('routes.posts.index');

// When active locale is `en`
// => 'Index of all posts'

// When active loclae is `ar`
// => 'فهرس جميع المشاركات'
Code language: TypeScript (typescript)

How do I include dynamic values in translations?

We often need to inject values determined at runtime into translation strings. For example, to show the logged-in user’s name in a translated message. We can use the {variable} syntax in translation messages to achieve this.

// src/locales/en/common.json

{
  "greeting": "Hello, {username} 👋",
}
Code language: JSON / JSON with Comments (json)
// src/locales/ar/common.json

{
  "greeting": "مرحبا {username} 👋",
}
Code language: JSON / JSON with Comments (json)

In our code, we pass in an object as the second argument to t(). The object should house an args object with key/value pairs corresponding to the {variable}s we set in our message.


i18n.t("common.greeting", { args: { username: "Noor" } });

// When active locale is `en`
// => "Hello, Noor 👋"
// When active locale is `ar`
// => "مرحبا Noor 👋"
Code language: TypeScript (typescript)

🔗 Resource » See the nestjs-i18n docs on Formatting for more.

How do I work with localized plurals?

Working with plurals is more than “singular and plural”. Different languages have a varying number of plural forms. English has two official plural forms, “a tree” and “many trees” — called one and other, respectively. Some languages, like Chinese, have one plural form. Arabic and Russian each have six.

🔗 Resource » The Unicode CLDR Language Plural Rules listing is a canonical source for languages’ plural forms.

nestjs-i18n provides good plural support. Let’s localize our app’s /today route to demonstrate.

🗒️ Note » Reminder that you can get the starter demo from the start branch of our GitHub repo.

First, let’s add our translation messages.

// src/locales/en/today.json

{
  "plannedRun": {
    "one": "Running a kilometer today",
    "other": "Running {count} kilometers today",
    "zero": "Resting today"
  }
}
Code language: JSON / JSON with Comments (json)

We add the one and other plural forms for English. nestjs-i18n allows an additional zero form. Note the interpolated {count} variable in the other form: It will be replaced with an integer counter at runtime.

🗒️ Note » See the CLDR Language Plural Rules for your language’s plural forms e.g. one, other, etc. The listing places forms under the Category header.

Now for the Arabic message, which has six forms.

// src/locales/ar/today.json

{
  "plannedRun": {
    "zero": "مستريح اليوم",
    "one": "سأجري كيلومتر واحد اليوم",
    "two": "سأجري كيلومترين اليوم",
    "few": "سأجري {count} كيلومترات اليوم",
    "many": "سأجري {count} كيلومتراً اليوم",
    "other": "سأجري {count} كيلومتر اليوم"
  }
}
Code language: JSON / JSON with Comments (json)

Now we can call i18n.t() with the parent key and a special count integer argument.

// src/today/today.controller.ts

  import { Controller, Get } from '@nestjs/common';
+ import { YcI18nService } from 'src/yc-i18n/yc-i18n.service';

  @Controller('today')
  export class TodayController {
+   constructor(private readonly i18n: YcI18nService) {}

    @Get()
    getTodayInfo() {
      return {
-       plannedRun: 'Running 4 kilometers today',
+       plannedRun: this.i18n.t('today.plannedRun', {
+         args: { count: 2 },
+       }),
      };
    }
  }
Code language: Diff (diff)

When nestjs-i18n sees the count arg, it determines that the message is a plural, and uses the value of count to determine the correct form for the active locale.

🗒️ Note » The counter arg must be called count.

Our REST client making a GET request to the URL /today with a query parameter locale=en. The response code is 200, and the response body contains { "plannedRun": "Running 2 kilometers today" }.

Our REST client making a GET request to the URL /today with a query parameter locale=ar. The response code is 200, and the response body contains { "plannedRun": "سأجري كيلومترين اليوم" }.

🗒️ Note » If nestjs-i18n doesn’t find an expected plural form in your translation message, a server-side error will be thrown.

🔗 Resource » Check out the Plurals nestjs-i18n documentation for more info.

🔗 Resource » Our quick Guide to Localizing Plurals covers ordinal plurals and other interesting plural-related tidbits.

How do I localize the database?

Our demo app has a blog posts resource with full CRUD and database persistence. However, posts currently presume a single locale. What if we wanted a post to be translated into different languages? There are two main strategies for localizing a database entity:

  • Translation columns on the main table rows e.g. instead of a title column, we have title_en, title_ar, etc.
  • Translations in a separate table: we move all translations into a separate translation table, creating a 1-n relationship between the main entity and its translations.

We’ll explore the simpler translations-columns-on-main-table strategy here. Check out the following resources if you’re interested in the 1-n solution.

🔗 Resource » What’s the Best Database Structure to Keep Multilingual Data? covers both strategies in detail.

🔗 Resource » If you want an example of the n-1 strategy implemented in Nest, we have one in our GitHub repo. Check out the tags module for the example.

Localized columns on the main model

For cases when we have a few columns and a few supported languages, it might make sense to simply put our translated fields directly into the main table. Let’s walk through how to do this for our blog posts.

// src/posts/entities/post.entity.ts

import {
  Column,
  Entity,
  PrimaryGeneratedColumn,
} from 'typeorm';

@Entity()
export class Post {
  @PrimaryGeneratedColumn()
  id: number;

  @Column()
- title: string;
+ title_en: string;

+ @Column({ nullable: true })
+ title_ar: string;

  @Column()
- content: string;
+ content_en: string;

+ @Column({ nullable: true })
+ content_ar: string;
}
Code language: Diff (diff)

We assume that English (en) is our source/default locale, and update our entity’s translatable fields with trailing locale suffixes (_en). We also add counterpart fields for each supported locale: title_ar and content_ar for our Arabic post title and body, respectively.

Since English is our source, we’ll always have an English version of a post, with an optional Arabic translation. So we’ve made our Arabic fields nullable. Let’s update our DTO validation to reflect this.

// src/posts/dto/create-post.dto.ts

  import {
    IsNotEmpty,
+   IsOptional,
    IsString,
    Length,
  } from 'class-validator';

  export class CreatePostDto {
    @IsString()
    @IsNotEmpty()
    @Length(1, 255)
-   title: string;
+   title_en: string;

+   @IsOptional()
+   @IsString()
+   @Length(1, 255)
+   title_ar: string;

    @IsString()
    @IsNotEmpty()
-   content: string;
+   content_en: string;

+   @IsOptional()
+   @IsString()
+   @Length(1, 255)
+   content_ar: string;
  }

Code language: Diff (diff)

It would be nice to retrieve post translations corresponding to the active locale. For example, a GET /posts?lang=ar request would return our posts with their Arabic titles, omitting the English ones. To achieve this, we need to update our PostsService.

First, let’s add a handy lang() method to our i18n wrapper service that returns the active locale.

// src/yc-i18n/yc-i18n.service.ts

  import { Injectable } from '@nestjs/common';
  import { I18nContext, I18nService } from 'nestjs-i18n';
  import {
    I18nPath,
    I18nTranslations,
  } from 'src/generated/i18n.generated';

+ export type SupportedLang = 'en' | 'ar';
+ export const defaultLang: SupportedLang = 'en';

  @Injectable()
  export class YcI18nService {
    constructor(
      private readonly i18n: I18nService<I18nTranslations>,
    ) {}

    t(key: I18nPath, options?: Record<string, any>) {
      return this.i18n.translate(key, {
        lang: this.lang(),
        ...options,
      });
    }

+   lang(): SupportedLang {
+     return (I18nContext.current()?.lang ||
+       defaultLang) as SupportedLang;
+   }
  }

Code language: Diff (diff)

We can now use lang() to determine the active locale and return corresponding post titles and content from our PostsService read methods.

// src/posts/posts.service.ts

import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { YcI18nService } from 'src/yc-i18n/yc-i18n.service';
import { Repository } from 'typeorm';
import { CreatePostDto } from './dto/create-post.dto';
import { UpdatePostDto } from './dto/update-post.dto';
import { Post } from './entities/post.entity';

export type TranslatedPostSummary = {
  id: number;
  title: string;
};

export type TranslatedPost = {
  id: number;
  title: string;
  content: string;
};

@Injectable()
export class PostsService {
  constructor(
    @InjectRepository(Post)
    private postRepo: Repository<Post>,
    private readonly i18n: YcI18nService,
  ) {}

  async findAll(): Promise<TranslatedPostSummary[]> {
    const lang = this.i18n.lang();

    const post = await this.postRepo.find({
      select: ['id', `title_${lang}`],
    });

    return post.map((p) => ({
      id: p.id,
      title: p[`title_${lang}`],
    }));
  }

  async findOne(
    id: number,
  ): Promise<TranslatedPost | null> {
    const lang = this.i18n.lang();

    const post = await this.postRepo.findOne({
      select: ['id', `title_${lang}`, `content_${lang}`],
      where: { id },
    });

    if (!post) {
      return null;
    }

    return {
      id: post.id,
      title: post[`title_${lang}`],
      content: post[`content_${lang}`],
    };
  }

  // Our create/update/delete methods are
  // unchanged...

  create(createPostDto: CreatePostDto): Promise<Post> {
    return this.postRepo.save({ ...createPostDto });
  }

  async update(
    id: number,
    updatePostDto: UpdatePostDto,
  ): Promise<Post | null> {
    const post = await this.postRepo.findOneBy({ id });

    if (post) {
      return this.postRepo.save({
        ...post,
        ...updatePostDto,
      });
    }

    return null;
  }

  async remove(id: number): Promise<boolean> {
    const post = await this.postRepo.findOneBy({ id });

    if (!post) {
      return false;
    }

    await this.postRepo.remove(post);

    return true;
  }
}
Code language: TypeScript (typescript)

We can create posts by providing title_en and content_en at a minimum.

Our REST client making a POST request to the URL /posts with form-encoded parameters title_en="Why I run" and content_en="There are many reasons...". The response code is 201 Created, and the response body contains { "id": 3, "title_en": "Why I run", "content": "There are many reasons...", title_ar: null, content_ar: null }.

We can PATCH our Arabic translations for a post at any time.

Our REST client making a PATCH request to the URL /posts/3 with form-encoded parameters title_ar="لماذا أركد" and content_ar="هناك العديد من الأسباب". The response code is 200, and the response body contains { "id": 3, "title_en": "Why I run", "content_en": "There are many reasons...", "title_ar": "لماذا أركد", "content_ar": "هناك العديد من الأسباب" }.

When we GET a post, we only see its text for the active locale.

Our REST client making a GET request to the URL /posts/3 with the query parameter lang=en. The response code is 200, and the response body contains { "id": 3, "title": "Why I run", "content": "There are many reasons..."}.

Our REST client making a GET request to the URL /posts/3 with the query parameter lang=ar. The response code is 200, and the response body contains { "id": 3, "title": "لماذا أركد", "content": "هناك العديد من الأسباب..."}.

This data translation strategy is relatively simple and works well for small models with a few supported locales.

🗒️ Note » If you’re working off the start branch in our Git repo, delete your db.sqlite file after making the above changes. In production, we’d use TypeORM migrations for a smooth data update, but for this demo we’re brute-forcing the update, making the new model incompatible with previous data. Deleting the db.sqlite file should cause the app to recreate it with our new schema.

How do I localize DTO validation messages?

nestjs-i18n has built-in support for DTO validation when using the class-validator package. Let’s utilize this to localize our posts’ DTO validation messages.

First, nestjs-i18n gives us a global pipe and filter that we should register in our main.ts.

// src/main.ts

  import { NestFactory } from '@nestjs/core';
+ import {
+   I18nValidationExceptionFilter,
+   I18nValidationPipe,
+ } from 'nestjs-i18n';
  import { AppModule } from './app.module';

  async function bootstrap() {
    const app = await NestFactory.create(AppModule);
+   app.useGlobalPipes(new I18nValidationPipe());
+   app.useGlobalFilters(
+     new I18nValidationExceptionFilter({
+       detailedErrors: true,
+     }),
+   );
    await app.listen(3000);
  }
  bootstrap();

Code language: Diff (diff)

🔗 Resource » See all available options for the I18nValidationExceptionFilter in the I18nValidationExceptionFilterOptions section of the docs.

Now let’s add our validation messages:

// src/locales/en/validation.json

{
  "required": "Please provide a value for `{property}`",
  "length": "`{property}` must be between {constraints.0} and {constraints.1} characters",
  "string": "`{property}` must be a string; you provided {value}"
}
Code language: JSON / JSON with Comments (json)
// src/locales/ar/validation.json

{
  "required": "يرجى تقديم قيمة لـ `{property}`",
  "length": "يجب أن يكون عدد أحرف `{property}` بين {constraints.0} و {constraints.1}.",
  "string": "`{property}` يجب أن تكون سلسلة (string) ؛ لقد قدمت {value}"
}
Code language: JSON / JSON with Comments (json)

{property}, value, and {constraints.index} will be replaced by the validated property name, given value, and a validation’s constraint values, respectively. We’ll see working in a moment.

🗒️ Note » {constraint.0} and {constraint.1} above are array arguments. Read more about those in the Formatting documentation.

We can now use our validation messages in DTO in two ways:

  • Passing the message key to the validation decorator directly.
  • Calling the i18nValidationMessage function provided by nestjs-i18n; We need to use this version when our messages access validation constraints.
// src/posts/dto/create-post.dto.ts

  import {
    IsNotEmpty,
    IsOptional,
    IsString,
    Length,
  } from 'class-validator';
+ import { i18nValidationMessage } from 'nestjs-i18n';

  export class CreatePostDto {
-   @IsString()
+   @IsString({ message: 'validation.string' })
-   @Length(1, 255)
+   // 1 and 255 are constraints we want passed to
+   // the validation message, so we use
+   // `i18nValidationMessage` here.
+   @Length(1, 255, {
+     message: i18nValidationMessage('validation.length'),
+   })
    title_en: string;

    @IsOptional()
-   @IsString()
+   @IsString({ message: 'validation.string' })
-   @Length(1, 255)
+   @Length(1, 255, {
+     message: i18nValidationMessage('validation.length'),
+   })
    title_ar: string;

-   @IsString()
+   @IsString({ message: 'validation.string' })
-   @IsNotEmpty()
+   @IsNotEmpty({ message: 'validation.required' })
    content_en: string;

    @IsOptional()
-   @IsString()
+   @IsString({ message: 'validation.string' })
-   @Length(1, 255)
+   @Length(1, 255, {
+     message: i18nValidationMessage('validation.length'),
+   })
    content_ar: string;
  }
Code language: Diff (diff)

Now our validation messages will be translated for the active locale in the request.

Our REST client making a POST request to the URL /posts. The request results in a 400 Bad Request status. The response body is a JSON object detailing validation errors. It includes a `statusCode` of 400, a `message` stating 'Bad Request', and an `errors` array with two objects. The first object has a `property` of `title_en`, an empty `target` object, no `children`, and `constraints` including 'isLength': '`title_en` must be between 1 and 255 characters' and 'isString': '`title_en` must be a string; you provided undefined'. The second object has a `property` of `content_en`, an empty `target` object, no `children`, and `constraints` including 'isNotEmpty': 'Please provide a value for `content_en`' and 'isString': '`content_en` must be a string; you provided undefined'.

Our REST client making a POST request to the URL /posts?lang=ar. The request results in a 400 Bad Request status. The response body is a JSON object detailing validation errors in Arabic. It includes a `statusCode` of 400, a `message` stating 'Bad Request', and an `errors` array with two objects. The first object has a `property` of `title_en`, an empty `target` object, no `children`, and `constraints` including 'isLength': 'يجب أن يكون عدد أحرف `title_en` بين 1 و 255.', and 'isString': 'يجب أن تكون سلسلة `title_en` (string); لقد قدمت undefined'. The second object has a `property` of `content_en`, an empty `target` object, no `children`, and `constraints` including 'isNotEmpty': 'يرجى تقديم قيمة ل`content_en`', and 'isString': 'يجب أن تكون سلسلة `content_en` (string); لقد قدمت undefined'.

🔗 Resource » If you want to validate your DTO manually, check out the Manual validation section of the nestjs-i18n docs.

🔗 Resource » Check out all the code for the demo on GitHub.

Upgrade your Nest localization

We hope you’ve found this guide to localizing NestJS with nestjs-i18n library helpful.

When you’re all set to start the translation process, rely on the Phrase Localization Platform to handle the heavy lifting. A specialized software localization platform, the Phrase Localization Platform comes with dozens of tools designed to automate your translation process and native integrations with platforms like GitHub, GitLab, and Bitbucket.

With its user-friendly strings editor, translators can effortlessly access your content and transfer it into your target languages. Once your translations are set, easily integrate them back into your project with a single command or automated sync.

This way, you can stay focused on what you love—your code. Sign up for a free trial and see for yourself why developers appreciate using The Phrase Localization Platform for software localization.