@veap/translatable stores multilingual field values for any model in one polymorphic translations table, so you can add translatable fields without a new table per model.
Install and migrate#
Add the package as a workspace dependency, then run its migration so the translations table exists:
import { runMigrations } from "@veap/database";
import { migrations } from "@veap/translatable/migrations";
await runMigrations("app", migrations);Make a model translatable#
Extend TranslatableModel and declare the translatable fields:
import { TranslatableModel } from "@veap/translatable";
export class BlogPost extends TranslatableModel {
static override table = "blog_posts";
static override translatable = ["title", "slug", "content"];
}If your model already extends another base class, use the mixin instead:
import { Translatable } from "@veap/translatable";
import { CustomBaseModel } from "./custom-base";
export class BlogCategory extends Translatable(CustomBaseModel) {
static override table = "blog_categories";
static override translatable = ["name", "description"];
}Translatable fields are proxied: reading returns the active locale, and assigning a plain string stages the value for the active locale.
post.setTranslation("title", "en", "Hello World");
post.setTranslation("title", "pl", "Witaj Świecie");
console.log(post.title); // active locale
// Set several locales at once
post.title = { en: "Hello World", pl: "Witaj Świecie" };
// Stage and persist
await post.save(); // translations are saved in the saved() hookRead translations#
post.getTranslation("title"); // active locale, with fallback
post.getTranslation("title", "pl"); // specific locale
post.getTranslations("title"); // { en: "...", pl: "..." }
post.getTranslations(); // { title: { ... }, content: { ... } }Fallback order: requested locale → fallback locale → default locale → the physical column on the model.
Manage locales#
import {
withLocale,
setLocale,
setFallbackLocale,
getLocale,
} from "@veap/translatable";
setFallbackLocale("en");
await withLocale("de", async () => {
console.log(getLocale()); // "de"
console.log(post.title); // German translation (or fallback)
});withLocale() uses AsyncLocalStorage, so it scopes the locale across asynchronous work. Outside a withLocale() scope, setLocale() changes a process-global value — be careful in a shared server process, where concurrent requests would otherwise observe the same locale.
Query by translation#
const posts = await BlogPost.query()
.whereTranslation("slug", "witaj-swiecie", "pl")
.get();
const matches = await BlogPost.query()
.whereTranslationLike("title", "%Veap%")
.get();
const ordered = await BlogPost.query()
.orderByTranslation("title", "asc", "pl")
.get();The query builder eager-loads translations by default; call withoutTranslations() to opt out for a specific query.
UI#
@veap/translatable/ui provides editor components: TranslatableField (one field with a locale switcher), TranslatableTabs (locale tabs) and TranslatableFormCard (a card of fields keyed by locale). They operate on Record<locale, value> and default to ["pl", "en"]; pass your locales explicitly.
Caveats#
- Saving translations issues additional writes (
updateOrCreate/forceDeleteper staged entry). Wrap multi-step operations intransaction()when atomicity matters. - Setting a translation to
nulldeletes the stored row on save. - Provide a value for every locale you require, or rely on the fallback chain; a missing translation can otherwise resolve to
null.