NestboltNestbolt

@nestbolt/translatable

Quick Start

Get up and running with @nestbolt/translatable in minutes -- module setup, entity creation, and automatic API locale resolution.

This guide walks you through a complete setup: registering the module, creating a translatable entity, setting and reading translations, and seeing automatic locale resolution in your API responses.

1. Register the Module

Import TranslatableModule and apply the TranslatableMiddleware in your AppModule:

import { MiddlewareConsumer, Module, NestModule } from "@nestjs/common";
import { TypeOrmModule } from "@nestjs/typeorm";
import {
  TranslatableModule,
  TranslatableMiddleware,
} from "@nestbolt/translatable";

@Module({
  imports: [
    TypeOrmModule.forRoot({
      type: "postgres",
      host: "localhost",
      port: 5432,
      database: "myapp",
      entities: [__dirname + "/**/*.entity{.ts,.js}"],
      synchronize: true, // development only
    }),
    TranslatableModule.forRoot({
      defaultLocale: "en",
      fallbackLocales: ["en"],
    }),
  ],
})
export class AppModule implements NestModule {
  configure(consumer: MiddlewareConsumer) {
    consumer.apply(TranslatableMiddleware).forRoutes("*");
  }
}

The TranslatableModule.forRoot() call registers the module globally -- you do not need to import it in every feature module. The TranslatableInterceptor is automatically registered as a global provider by the module.

The middleware reads the Accept-Language header from incoming requests and sets the locale for the request lifecycle using AsyncLocalStorage. The interceptor then uses this locale to transform translatable fields in the response.

2. Create a Translatable Entity

Create an entity that uses the @Translatable() decorator and TranslatableMixin:

import { Entity, PrimaryGeneratedColumn, Column, BaseEntity } from "typeorm";
import { Translatable, TranslatableMixin } from "@nestbolt/translatable";

@Entity()
export class Product extends TranslatableMixin(BaseEntity) {
  @PrimaryGeneratedColumn()
  id: number;

  @Translatable()
  @Column({ type: "jsonb", default: {} })
  name: Record<string, string>;

  @Translatable()
  @Column({ type: "jsonb", default: {} })
  description: Record<string, string>;

  @Column()
  slug: string;

  @Column({ type: "decimal", precision: 10, scale: 2 })
  price: number;
}

The @Translatable() decorator marks name and description as translatable fields. The TranslatableMixin(BaseEntity) call adds all the translation methods (setTranslation, getTranslation, etc.) to your entity class. Non-translatable fields like slug and price are left untouched.

3. Set Translations

Use the chainable setTranslation method in your service:

import { Injectable } from "@nestjs/common";
import { InjectRepository } from "@nestjs/typeorm";
import { Repository } from "typeorm";
import { Product } from "./product.entity";

@Injectable()
export class ProductService {
  constructor(
    @InjectRepository(Product)
    private readonly repo: Repository<Product>,
  ) {}

  async create() {
    const product = new Product();

    product
      .setTranslation("name", "en", "Laptop")
      .setTranslation("name", "ar", "حاسوب محمول")
      .setTranslation("name", "fr", "Ordinateur portable");

    product
      .setTranslation("description", "en", "A powerful laptop for professionals")
      .setTranslation("description", "ar", "حاسوب محمول قوي للمحترفين")
      .setTranslation("description", "fr", "Un ordinateur portable puissant pour les professionnels");

    product.slug = "laptop";
    product.price = 999.99;

    return this.repo.save(product);
  }
}

You can also set multiple translations at once with setTranslations:

product.setTranslations("name", {
  en: "Laptop",
  ar: "حاسوب محمول",
  fr: "Ordinateur portable",
});

4. Read Translations Programmatically

// Get a translation for a specific locale
product.getTranslation("name", "en");
// → "Laptop"

product.getTranslation("name", "ar");
// → "حاسوب محمول"

// Get all translations for a field
product.getTranslations("name");
// → { en: "Laptop", ar: "حاسوب محمول", fr: "Ordinateur portable" }

// Get all locales that have translations
product.locales();
// → ["en", "ar", "fr"]

5. Create a Controller

Your controller does not need any translation-specific code. The interceptor handles everything automatically:

import { Controller, Get, Param } from "@nestjs/common";
import { InjectRepository } from "@nestjs/typeorm";
import { Repository } from "typeorm";
import { Product } from "./product.entity";

@Controller("products")
export class ProductController {
  constructor(
    @InjectRepository(Product)
    private readonly repo: Repository<Product>,
  ) {}

  @Get()
  findAll() {
    return this.repo.find();
  }

  @Get(":id")
  findOne(@Param("id") id: number) {
    return this.repo.findOneBy({ id });
  }
}

6. API Response Examples

With Accept-Language Header

When a client sends a request with the Accept-Language header, translatable fields are resolved to a single string in the requested locale:

GET /products/1
Accept-Language: ar
{
  "id": 1,
  "name": "حاسوب محمول",
  "description": "حاسوب محمول قوي للمحترفين",
  "slug": "laptop",
  "price": 999.99
}

Without Accept-Language Header

When no Accept-Language header is present, translatable fields return the full translation map:

GET /products/1
{
  "id": 1,
  "name": {
    "en": "Laptop",
    "ar": "حاسوب محمول",
    "fr": "Ordinateur portable"
  },
  "description": {
    "en": "A powerful laptop for professionals",
    "ar": "حاسوب محمول قوي للمحترفين",
    "fr": "Un ordinateur portable puissant pour les professionnels"
  },
  "slug": "laptop",
  "price": 999.99
}

Listing Endpoints

The interceptor handles arrays and nested structures automatically:

GET /products
Accept-Language: en
[
  {
    "id": 1,
    "name": "Laptop",
    "description": "A powerful laptop for professionals",
    "slug": "laptop",
    "price": 999.99
  },
  {
    "id": 2,
    "name": "Keyboard",
    "description": "Mechanical keyboard with RGB lighting",
    "slug": "keyboard",
    "price": 149.99
  }
]

Wrapped/Paginated Responses

If your controller returns a wrapped response (for example, paginated data), the interceptor traverses nested objects and arrays:

@Get()
async findAll() {
  const [data, total] = await this.repo.findAndCount();
  return { data, total };
}
GET /products
Accept-Language: ar
{
  "data": [
    {
      "id": 1,
      "name": "حاسوب محمول",
      "description": "حاسوب محمول قوي للمحترفين",
      "slug": "laptop",
      "price": 999.99
    }
  ],
  "total": 1
}

Fallback Behavior

When the requested locale is missing for a field, the system tries each locale in the fallbackLocales chain:

GET /products/1
Accept-Language: de

If the product has no German translation but has English and French, and your fallback chain is ["en", "fr"], the response will use the English translation:

{
  "id": 1,
  "name": "Laptop",
  "slug": "laptop",
  "price": 999.99
}

See Configuration for details on configuring fallback behavior.