@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:
-
TranslatableMiddlewarereads theAccept-Languageheader from incoming HTTP requests and sets the locale for the request lifecycle using Node.jsAsyncLocalStorage. -
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 Value | Parsed Locale |
|---|---|
ar | ar |
en-US | en-US |
fr, en;q=0.9 | fr |
en-US;q=0.9, ar;q=0.8 | en-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:
-
Check for
@SkipTranslation()-- If the handler or controller is decorated with@SkipTranslation(), the data is returned as-is. -
Check for
Accept-Languageheader -- If no header was present, the data is returned as-is (full translation maps). -
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 usingTranslatableService.resolveLocale(). - For arrays, each element is processed.
- For plain objects (e.g.,
{ data: [...], total: 5 }), nested values are recursively checked.
- For translatable entities (objects whose constructor has
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): TThe 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:
| Method | Returns | Description |
|---|---|---|
getLocale() | string | Get the current locale from AsyncLocalStorage, or the defaultLocale if none is set. |
getDefaultLocale() | string | Get the configured default locale. |
getFallbackLocale() | string | Get the first fallback locale (backward compatibility). |
getFallbackLocales() | string[] | Get the full ordered fallback locale chain. |
runWithLocale(locale, fn) | T | Execute a function with a specific locale context. |
resolveLocale(requested, available, useFallback) | string | Resolve 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 Type | Header Source |
|---|---|
"http" | context.switchToHttp().getRequest().headers["accept-language"] |
"graphql" | context.getArgs()[2].req.headers["accept-language"] |
| Other | No locale resolution -- data passes through unchanged |