Translations are stored in three tables: Bundle, Message, and Variant.

The data model is relational (SQL) and heavily inspired by Unicode MessageFormat 2.

Bundle (greeting)
├── Message (en)
│   └── Variant ("Hello {name}!")
├── Message (de)
│   └── Variant ("Hallo {name}!")
└── Message (fr)
    └── Variant ("Bonjour {name}!")

Bundle

A bundle groups translations by key. One bundle = one translatable unit across all locales.

type Bundle = {
  id: string; // e.g., "greeting", "error_404"
  declarations: Declaration[];
};

The id is your translation key — what you reference in code. The id is assumed to be stable; changing it would break all references. Declarations define variables available to all messages in the bundle.

See Message Shapes for concrete JSON examples of declarations, selectors, matches, and pattern parts.

Message

A message is a locale-specific translation. One message per locale per bundle.

type Message = {
  id: string; // auto-generated UUID
  bundleId: string; // references Bundle.id
  locale: string; // e.g., "en", "de", "fr"
  selectors: VariableReference[];
};

Selectors are used for conditional matching (plurals, gender, etc.). If your message has no conditions, selectors is empty.

Variant

A variant is the actual text pattern. Most messages have one variant, but pluralization requires multiple.

type Variant = {
  id: string; // auto-generated UUID
  messageId: string; // references Message.id
  matches: Match[]; // conditions for this variant
  pattern: Pattern; // the text content
};

Simple example

A greeting with no pluralization:

Bundle: "greeting"
└── Message: locale="en"
    └── Variant: pattern="Hello {name}!"
└── Message: locale="de"
    └── Variant: pattern="Hallo {name}!"

Pluralization example

A message with plural forms needs multiple variants:

Bundle: "items_count"
  declarations: [{ type: "input-variable", name: "count" }]
└── Message: locale="en", selectors=["count"]
    └── Variant: matches=[{key: "count", value: "one"}], pattern="One item"
    └── Variant: matches=[{key: "count", value: "other"}], pattern="{count} items"
└── Message: locale="de", selectors=["count"]
    └── Variant: matches=[{key: "count", value: "one"}], pattern="Ein Artikel"
    └── Variant: matches=[{key: "count", value: "other"}], pattern="{count} Artikel"

Pattern syntax

Patterns contain text, expressions (variables), and markup placeholders:

  • Text — Plain strings: "Hello world"
  • Expression — Variables wrapped in braces: "Hello {name}!"
  • Markup start / end — Wrapping tags: "{#b}Hello{/b}"
  • Markup standalone — Self-closing tags: "{#icon/}"
  • Markup options — Named values stored on markup nodes (options)
  • Markup attributes — Metadata flags/values stored on markup nodes (attributes)

Expressions can have annotations for formatting:

{count}              // plain variable
{count: number}      // format as number
{date: date}         // format as date
{count: plural}      // pluralization
{#link}Open{/link}   // markup wrapper
{#icon/}             // standalone markup

Markup options/attributes are part of the SDK data model. Support in import/export plugins can vary by format.

Querying the data model

Use Kysely to query messages:

// Get all messages for a bundle
const messages = await project.db
  .selectFrom("message")
  .where("bundleId", "=", "greeting")
  .selectAll()
  .execute();

// Get all bundles with their messages
const bundles = await project.db
  .selectFrom("bundle")
  .leftJoin("message", "message.bundleId", "bundle.id")
  .selectAll()
  .execute();

// Find missing translations
const allBundles = await project.db.selectFrom("bundle").selectAll().execute();
const germanMessages = await project.db
  .selectFrom("message")
  .select("bundleId")
  .where("locale", "=", "de")
  .execute();

const translatedBundleIds = new Set(
  germanMessages.map((message) => message.bundleId),
);
const missing = allBundles.filter(
  (bundle) => translatedBundleIds.has(bundle.id) === false,
);

Next steps