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
Software Engineer · Nestbolt
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
Productentity withnameanddescriptionfields that hold translations for any number of locales product.setTranslation("name", "en", "Laptop")chainable API for writing translations- A middleware that reads the
Accept-Languageheader into a request-scoped locale - An interceptor that flattens translatable fields to a single string in responses, transparently
- Fallback:
Accept-Language: dereturns the English translation if German doesn't exist
Prerequisites
- Node.js 18 or later
- A NestJS 10+ project with TypeORM configured
- A Postgres database (
jsonbcolumn type) or MySQL 5.7+ (JSONcolumn type)
1. Pick the storage shape
You have three options for storing translations, and the choice shapes everything else:
| Option | Schema | Pros | Cons |
|---|---|---|---|
| Per-language columns | name_en, name_ar, name_fr | Simple, easy to index | Schema change to add a language |
| Translations table | translations(entity_type, entity_id, field, locale, value) | Fully relational, queryable | Joins on every read |
| JSON column | name jsonb, description jsonb | One row, no joins, ergonomic | Field-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
| Pitfall | What goes wrong | Fix |
|---|---|---|
| Per-language columns | Adding a language is a migration | Use a JSON column with locale → value map |
Storing translations as text and JSON.parse-ing | Lose JSON operators, slow queries | Use the native jsonb / JSON column type |
Not setting default: {} | First setTranslation crashes on undefined | Set a default: {} on the column |
| Forgetting fallback locales | Missing locale returns null, breaks the UI | Always configure a fallback chain |
| Locale resolved per-service-call | Background jobs lose locale | Pass it explicitly, or use AsyncLocalStorage |
| Locale code mismatch | en-US doesn't match stored en | Normalize: take the language part before the hyphen |
| Translating everything | Slugs and IDs become a translation map | Only annotate user-facing display fields |
| Caching responses across users | Locale-A user sees locale-B response | Add 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 andTranslatableMixin(Base)withsetTranslation,getTranslation,getTranslations,locales, andsetTranslationsTranslatableMiddlewareforAccept-Languageresolution- A globally registered
TranslatableInterceptorthat flattens translatable fields across arrays, nested objects, and wrapped responses ({ data, total }shapes work out of the box) - Configurable
defaultLocaleandfallbackLocaleschain - Behavior for missing
Accept-Languagereturning 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.
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.