Back to blog
translatable·8 min read

i18n in NestJS — How to Translate Database Content (Not Just Static Strings)

A complete walkthrough of localizing dynamic content in NestJS — store translations in JSONB, resolve the locale per request, return a single translated string in API responses, and fall back gracefully when a locale is missing.

Khatab Wedaa

Khatab Wedaa

Software Engineer · Nestbolt

i18n in NestJS — How to Translate Database Content (Not Just Static Strings)

Most i18n tutorials for NestJS show you how to translate static strings — error messages, email templates, the labels in your UI. That's the easy half. The hard half is translating content: product names, blog posts, category labels, anything a user types into your admin panel that another user reads in a different language. Every team eventually hits this, and the first instinct — "let's just add a name_en, name_ar, name_fr column for every translatable field" — works for two languages and falls apart at three.

This guide walks through localizing dynamic content properly in a NestJS application. We'll store translations in a JSON column, resolve the locale per request from Accept-Language, automatically transform translatable fields in API responses to a single string, and fall back to a default locale when the requested one is missing. The patterns work on Postgres (jsonb) and MySQL 5.7+ (JSON).

What you'll build

By the end of this tutorial, your NestJS app will support:

  • A Product entity with name and description fields that hold translations for any number of locales
  • product.setTranslation("name", "en", "Laptop") chainable API for writing translations
  • A middleware that reads the Accept-Language header into a request-scoped locale
  • An interceptor that flattens translatable fields to a single string in responses, transparently
  • Fallback: Accept-Language: de returns the English translation if German doesn't exist

Prerequisites

  • Node.js 18 or later
  • A NestJS 10+ project with TypeORM configured
  • A Postgres database (jsonb column type) or MySQL 5.7+ (JSON column type)

1. Pick the storage shape

You have three options for storing translations, and the choice shapes everything else:

OptionSchemaProsCons
Per-language columnsname_en, name_ar, name_frSimple, easy to indexSchema change to add a language
Translations tabletranslations(entity_type, entity_id, field, locale, value)Fully relational, queryableJoins on every read
JSON columnname jsonb, description jsonbOne row, no joins, ergonomicField-level filters need JSON operators

For most CRUD apps the JSON column is the right default. Postgres jsonb supports indexing on individual keys (->>), so "find products with an English name containing 'laptop'" stays fast. The whole entity loads in a single row, which is what you want for the dominant read pattern: "show me this product in the user's language."

The downside is that cross-language queries ("find all products that have a French translation") are awkward. If that's your bread-and-butter access pattern, the translations table wins. For everything else, JSON.

2. Define the translatable entity

Translatable fields are JSON columns shaped as Record<string, string> — a map from locale code to translated value:

// src/products/product.entity.ts
import {
  Entity,
  PrimaryGeneratedColumn,
  Column,
  BaseEntity,
} from "typeorm";
 
@Entity("products")
export class Product extends BaseEntity {
  @PrimaryGeneratedColumn()
  id!: number;
 
  @Column({ type: "jsonb", default: {} })
  name!: Record<string, string>;
 
  @Column({ type: "jsonb", default: {} })
  description!: Record<string, string>;
 
  @Column()
  slug!: string;
 
  @Column({ type: "decimal", precision: 10, scale: 2 })
  price!: number;
}

A row in the table looks like:

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

Adding a fourth language is one setTranslation() call — no migration.

3. The translatable mixin

To avoid spelling out product.name = { ...product.name, en: "Laptop" } everywhere, mixin a small set of helpers:

// src/translatable/translatable.mixin.ts
const TRANSLATABLE_FIELDS = Symbol("translatable-fields");
 
export const Translatable =
  (): PropertyDecorator =>
  (target, key) => {
    const list: string[] =
      Reflect.getMetadata(TRANSLATABLE_FIELDS, target.constructor) ?? [];
    if (!list.includes(key as string)) list.push(key as string);
    Reflect.defineMetadata(TRANSLATABLE_FIELDS, list, target.constructor);
  };
 
type Constructor<T = unknown> = new (...args: any[]) => T;
 
export function TranslatableMixin<TBase extends Constructor>(Base: TBase) {
  class WithTranslations extends Base {
    setTranslation(field: string, locale: string, value: string) {
      const current = (this as Record<string, unknown>)[field] as
        | Record<string, string>
        | undefined;
      (this as Record<string, unknown>)[field] = { ...(current ?? {}), [locale]: value };
      return this;
    }
 
    setTranslations(field: string, translations: Record<string, string>) {
      (this as Record<string, unknown>)[field] = { ...translations };
      return this;
    }
 
    getTranslation(field: string, locale: string): string | undefined {
      const map = (this as Record<string, unknown>)[field] as
        | Record<string, string>
        | undefined;
      return map?.[locale];
    }
 
    getTranslations(field: string): Record<string, string> {
      return ((this as Record<string, unknown>)[field] ?? {}) as Record<string, string>;
    }
 
    locales(): string[] {
      const fields: string[] =
        Reflect.getMetadata(TRANSLATABLE_FIELDS, this.constructor) ?? [];
      const set = new Set<string>();
      for (const f of fields) {
        for (const locale of Object.keys(this.getTranslations(f))) set.add(locale);
      }
      return [...set];
    }
  }
  return WithTranslations;
}
 
export const getTranslatableFields = (target: unknown): string[] =>
  Reflect.getMetadata(TRANSLATABLE_FIELDS, (target as { constructor: object }).constructor) ?? [];

Annotate translatable fields and extend the mixin:

@Entity("products")
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;
}

Writing translations is now a chained call:

const product = new Product();
product
  .setTranslation("name", "en", "Laptop")
  .setTranslation("name", "ar", "حاسوب محمول")
  .setTranslation("name", "fr", "Ordinateur portable");
product.slug = "laptop";
await this.repo.save(product);

4. Resolve the locale per request

The locale comes from the Accept-Language header — that's the standards answer. A user can override via ?locale=ar or a cookie if you want, but Accept-Language is the right default.

Use AsyncLocalStorage to make the resolved locale available anywhere in the request without passing it through every function:

// src/translatable/locale.context.ts
import { AsyncLocalStorage } from "node:async_hooks";
 
export const localeStorage = new AsyncLocalStorage<{ locale: string }>();
// src/translatable/translatable.middleware.ts
@Injectable()
export class TranslatableMiddleware implements NestMiddleware {
  constructor(
    @Inject(TRANSLATABLE_OPTIONS)
    private readonly options: { defaultLocale: string },
  ) {}
 
  use(req: Request, _res: Response, next: NextFunction) {
    const header = req.headers["accept-language"];
    const locale = parseAcceptLanguage(header) ?? this.options.defaultLocale;
    localeStorage.run({ locale }, () => next());
  }
}
 
function parseAcceptLanguage(header?: string | string[]): string | undefined {
  if (!header) return undefined;
  const value = Array.isArray(header) ? header[0] : header;
  return value.split(",")[0]?.split(";")[0]?.trim().toLowerCase();
}

Apply it in your AppModule:

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

5. Flatten responses with an interceptor

The point of all this is that controllers stay clean. They return entities — findOneBy({ id }) — and the interceptor handles the locale flattening on the way out:

// src/translatable/translatable.interceptor.ts
@Injectable()
export class TranslatableInterceptor implements NestInterceptor {
  constructor(
    @Inject(TRANSLATABLE_OPTIONS)
    private readonly options: { defaultLocale: string; fallbackLocales: string[] },
  ) {}
 
  intercept(_ctx: ExecutionContext, next: CallHandler) {
    return next.handle().pipe(map((data) => this.transform(data)));
  }
 
  private transform(value: unknown): unknown {
    if (Array.isArray(value)) return value.map((v) => this.transform(v));
    if (!value || typeof value !== "object") return value;
 
    const fields = getTranslatableFields(value);
    if (fields.length === 0) {
      const out: Record<string, unknown> = { ...(value as object) };
      for (const k of Object.keys(out)) out[k] = this.transform(out[k]);
      return out;
    }
 
    const locale = localeStorage.getStore()?.locale;
    if (!locale) return value; // no header — return raw maps
 
    const out: Record<string, unknown> = { ...(value as object) };
    for (const field of fields) {
      const map = (out[field] ?? {}) as Record<string, string>;
      out[field] =
        map[locale] ??
        this.options.fallbackLocales.map((f) => map[f]).find(Boolean) ??
        undefined;
    }
    return out;
  }
}

Register it globally in the module's forRoot(). Now the controller is just:

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

And the response respects Accept-Language:

  • Accept-Language: ar
    { "id": 1, "name": "حاسوب محمول", "slug": "laptop", "price": 999.99 }
  • Accept-Language: en
    { "id": 1, "name": "Laptop", "slug": "laptop", "price": 999.99 }
  • Accept-Language: de (no German, falls back to English) →
    { "id": 1, "name": "Laptop", "slug": "laptop", "price": 999.99 }
  • No header at all → the full map is returned, useful for admin UIs that edit translations.

The interceptor walks arrays and nested objects recursively, so a paginated response like { data: [...], total: 12 } works without any extra wiring.

6. Common pitfalls

PitfallWhat goes wrongFix
Per-language columnsAdding a language is a migrationUse a JSON column with locale → value map
Storing translations as text and JSON.parse-ingLose JSON operators, slow queriesUse the native jsonb / JSON column type
Not setting default: {}First setTranslation crashes on undefinedSet a default: {} on the column
Forgetting fallback localesMissing locale returns null, breaks the UIAlways configure a fallback chain
Locale resolved per-service-callBackground jobs lose localePass it explicitly, or use AsyncLocalStorage
Locale code mismatchen-US doesn't match stored enNormalize: take the language part before the hyphen
Translating everythingSlugs and IDs become a translation mapOnly annotate user-facing display fields
Caching responses across usersLocale-A user sees locale-B responseAdd Vary: Accept-Language to cache headers

The cache one is the sneakiest. A CDN that caches /products/1 will serve the first locale's response to everyone unless you tell it to vary by Accept-Language. Either set the header or drop the locale into the URL (/en/products/1) — both work.

Going further: @nestbolt/translatable

Translation infrastructure is one of those features that's a satisfying weekend project the first time and a chore by the third. The mixin, the metadata registry, the middleware, the AsyncLocalStorage plumbing, the interceptor, and the fallback logic are all small but easy to get subtly wrong.

@nestbolt/translatable packages it up:

  • @Translatable() decorator and TranslatableMixin(Base) with setTranslation, getTranslation, getTranslations, locales, and setTranslations
  • TranslatableMiddleware for Accept-Language resolution
  • A globally registered TranslatableInterceptor that flattens translatable fields across arrays, nested objects, and wrapped responses ({ data, total } shapes work out of the box)
  • Configurable defaultLocale and fallbackLocales chain
  • Behavior for missing Accept-Language returning the full map — useful for admin UIs

Setup is two lines: TranslatableModule.forRoot({ defaultLocale, fallbackLocales }) and consumer.apply(TranslatableMiddleware).forRoutes("*"). The full API is in the translatable quick-start.

Wrapping up

Localizing static strings is a solved problem — nestjs-i18n and friends do it well. Localizing content is where most apps stumble, because the storage shape and the API shape have to be designed together. The pattern here — JSON columns, mixin helpers, AsyncLocalStorage for the locale, interceptor for the flattening — works whether you have two languages or twenty, scales to nested responses, and keeps your controllers untouched.

If this saved you from designing it from scratch, star the repo. If you want a follow-up on RTL handling, language-aware search, or per-tenant locale defaults, open an issue on GitHub.

Khatab Wedaa

Written by Khatab Wedaa

Software Engineer · Nestbolt

Building open-source NestJS packages — authentication, permissions, audit logs, media uploads, and the patterns every backend ends up rebuilding.