NestboltNestbolt

@nestbolt/translatable

API Locale Resolution

Automatic locale-aware API responses with TranslatableMiddleware, TranslatableInterceptor, Accept-Language header, @SkipTranslation, and GraphQL support.

The recommended way to use @nestbolt/translatable is with the built-in middleware and interceptor. Together, they provide automatic locale-aware API responses with zero boilerplate in your controllers.

How It Works

The locale resolution pipeline has two components:

  1. TranslatableMiddleware reads the Accept-Language header from incoming HTTP requests and sets the locale for the request lifecycle using Node.js AsyncLocalStorage.

  2. TranslatableInterceptor (registered globally by the module) runs after your controller handler. It inspects the response data, finds any translatable entities, and resolves their translatable fields to the active locale.

The result is that your controllers return entities as usual, and the response is automatically transformed based on the client's language preference.

Setup

Apply the middleware in your AppModule. The interceptor is already registered globally by TranslatableModule, so no additional configuration is needed for it.

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

@Module({
  imports: [
    TranslatableModule.forRoot({
      defaultLocale: "en",
      fallbackLocales: ["en"],
    }),
  ],
})
export class AppModule implements NestModule {
  configure(consumer: MiddlewareConsumer) {
    consumer.apply(TranslatableMiddleware).forRoutes("*");
  }
}

Your controllers need no translation-specific code:

@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 });
  }
}

Accept-Language Header

The middleware parses the Accept-Language header to determine the request locale. It extracts the first locale from the header value, stripping any quality values:

Header ValueParsed Locale
arar
en-USen-US
fr, en;q=0.9fr
en-US;q=0.9, ar;q=0.8en-US
(not present)(no locale set -- interceptor returns raw maps)

When a locale is parsed, the middleware wraps the rest of the request lifecycle in TranslatableService.runWithLocale(), which stores the locale in AsyncLocalStorage. This locale is then available to the interceptor and to any code that calls TranslatableService.getLocale() or entity.getTranslation() without an explicit locale argument.

When no Accept-Language header is present, no locale is set, and the interceptor passes the response data through without resolution -- translatable fields remain as their full JSON translation maps.

TranslatableInterceptor

The interceptor is registered globally by TranslatableModule and runs on every request. It operates on the response data returned by your controller handler.

Resolution Logic

The interceptor performs the following:

  1. Check for @SkipTranslation() -- If the handler or controller is decorated with @SkipTranslation(), the data is returned as-is.

  2. Check for Accept-Language header -- If no header was present, the data is returned as-is (full translation maps).

  3. Resolve translations -- The interceptor recursively walks the response data structure:

    • For translatable entities (objects whose constructor has @Translatable() metadata), each translatable field is resolved to a single string using TranslatableService.resolveLocale().
    • For arrays, each element is processed.
    • For plain objects (e.g., { data: [...], total: 5 }), nested values are recursively checked.

Nested and Wrapped Responses

The interceptor handles common response patterns automatically:

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

// Array of entities
@Get()
findAll() {
  return this.repo.find();
}

// Paginated / wrapped response
@Get()
async findAllPaginated() {
  const [data, total] = await this.repo.findAndCount();
  return { data, total, page: 1 };
}

All three patterns produce correctly resolved translations when the Accept-Language header is set.

@SkipTranslation()

Use the @SkipTranslation() decorator to bypass automatic locale resolution on specific routes or entire controllers. This is useful for admin panels, content editors, or any endpoint that needs the full translation maps for editing.

On a Single Route

import { SkipTranslation } from "@nestbolt/translatable";

@Controller("products")
export class ProductController {
  @Get()
  findAll() {
    return this.repo.find();
    // With Accept-Language: ar → { "name": "حاسوب محمول" }
  }

  @SkipTranslation()
  @Get("admin")
  findAllAdmin() {
    return this.repo.find();
    // Always returns → { "name": { "en": "Laptop", "ar": "حاسوب محمول" } }
  }
}

On an Entire Controller

import { SkipTranslation } from "@nestbolt/translatable";

@SkipTranslation()
@Controller("admin/products")
export class AdminProductController {
  @Get()
  findAll() {
    return this.repo.find();
    // Always returns full JSON, regardless of Accept-Language header
  }

  @Get(":id")
  findOne(@Param("id") id: number) {
    return this.repo.findOneBy({ id });
    // Also returns full JSON
  }
}

The decorator uses NestJS SetMetadata internally. When applied to a controller, it affects all routes in that controller. When applied to a specific handler, only that handler is affected.

TranslatableService.runWithLocale()

For scenarios outside of HTTP requests (such as cron jobs, queue workers, or programmatic operations), you can use TranslatableService.runWithLocale() to execute a function with a specific locale context:

import { Injectable } from "@nestjs/common";
import { TranslatableService } from "@nestbolt/translatable";

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

  async sendLocalizedEmail(productId: number, userLocale: string) {
    const product = await this.repo.findOneBy({ id: productId });

    // Run with a specific locale context
    const localizedName = this.translatableService.runWithLocale(
      userLocale,
      () => product.getTranslation("name"),
    );

    // localizedName is resolved in the user's locale
    await this.mailer.send({
      subject: `New product: ${localizedName}`,
      // ...
    });
  }
}

The runWithLocale method uses AsyncLocalStorage.run() under the hood, so the locale is available to any code executed within the callback, including nested function calls and async operations.

Signature:

runWithLocale<T>(locale: string, fn: () => T): T

The callback can be synchronous or asynchronous (returning a Promise). The return value of runWithLocale matches the return type of the callback.

TranslatableService Methods

The TranslatableService provides several methods for working with locale state:

MethodReturnsDescription
getLocale()stringGet the current locale from AsyncLocalStorage, or the defaultLocale if none is set.
getDefaultLocale()stringGet the configured default locale.
getFallbackLocale()stringGet the first fallback locale (backward compatibility).
getFallbackLocales()string[]Get the full ordered fallback locale chain.
runWithLocale(locale, fn)TExecute a function with a specific locale context.
resolveLocale(requested, available, useFallback)stringResolve the best locale given a requested locale and a list of available locales.

Example: Accessing Locale State in a Service

@Injectable()
export class ProductService {
  constructor(private readonly translatableService: TranslatableService) {}

  getLocaleInfo() {
    return {
      currentLocale: this.translatableService.getLocale(),
      defaultLocale: this.translatableService.getDefaultLocale(),
      fallbackChain: this.translatableService.getFallbackLocales(),
    };
  }

  resolveForProduct(product: Product, requestedLocale: string) {
    const availableLocales = product.getTranslatedLocales("name");
    const bestLocale = this.translatableService.resolveLocale(
      requestedLocale,
      availableLocales,
      true,
    );
    return product.getTranslation("name", bestLocale, false);
  }
}

GraphQL Support

The TranslatableInterceptor works seamlessly with GraphQL resolvers. It detects the execution context type automatically -- no extra configuration or decorators needed.

Optional peer dependency: Install @nestjs/graphql to enable GraphQL support.

Setup

Pass the HTTP request through your GraphQL context. This is the standard NestJS pattern for Apollo:

import { ApolloDriver, ApolloDriverConfig } from "@nestjs/apollo";
import { GraphQLModule } from "@nestjs/graphql";

@Module({
  imports: [
    GraphQLModule.forRoot<ApolloDriverConfig>({
      driver: ApolloDriver,
      autoSchemaFile: true,
      context: ({ req }) => ({ req }), // pass request to GQL context
    }),
    TranslatableModule.forRoot({
      defaultLocale: "en",
      fallbackLocales: ["en"],
    }),
  ],
})
export class AppModule implements NestModule {
  configure(consumer: MiddlewareConsumer) {
    consumer.apply(TranslatableMiddleware).forRoutes("*");
  }
}

Your resolvers work the same as controllers -- no translation-specific code needed:

@Resolver(() => Product)
export class ProductResolver {
  constructor(
    @InjectRepository(Product)
    private readonly repo: Repository<Product>,
  ) {}

  @Query(() => [Product])
  products() {
    return this.repo.find();
  }
}

The interceptor reads Accept-Language from the underlying HTTP request via context.getArgs()[2].req.headers:

# With Accept-Language: ar header
query {
  products {
    id
    name
    slug
  }
}
# → { "data": { "products": [{ "id": 1, "name": "حاسوب محمول", "slug": "laptop" }] } }

@SkipTranslation() with GraphQL

The @SkipTranslation() decorator works on resolvers too:

@SkipTranslation()
@Query(() => [Product])
adminProducts() {
  return this.repo.find();
  // Always returns full JSON maps
}

Context Detection

The interceptor checks context.getType() to determine how to extract the Accept-Language header:

Context TypeHeader Source
"http"context.switchToHttp().getRequest().headers["accept-language"]
"graphql"context.getArgs()[2].req.headers["accept-language"]
OtherNo locale resolution -- data passes through unchanged