Software localization
A Guide to NestJS Localization

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 DTOsqlite3
(5.1) — database driver for developmenttypeorm
(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 Nestclass-transformer
(0.5) — used along with class-validator for decorator-based DTO validationclass-validator (0.14)
— used for decorator-based DTO validationnestjs-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.
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.
🔗 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.
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.
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.
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
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.
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.
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
.
🔗 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.
🗒️ 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
.
🗒️ 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
and title_en
content_en
at a minimum.
We can PATCH
our Arabic translations for a post at any time.
When we GET
a post, we only see its text for the active locale.
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 bynestjs-i18n
; We need to use this version when our messages access validationconstraints
.
// 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.
🔗 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.