Content translations

Translate record fields into multiple languages with per-locale values, a side-by-side editor, AI translation, and a simple localized API.

#Content translations

Any field can be marked translatable, so a single record holds one value per language. The API keeps the shape simple: it returns the language you ask for by default, or the whole { locale: value } map when you want to edit every language at once.

One language? Nothing changes. With a single language configured, none of the translation UI appears and records behave exactly as before. Everything below turns on only when you add a second language.

#1. Set up your languages

Go to Settings → Languages. This is the one canonical list, used by every collection. For each language set:

  • a code — a BCP-47 tag such as en-AU, de-DE, or zh-Hans;
  • a display name and direction (LTR / RTL);
  • an optional fallback chain (e.g. de-CH → de-DE);
  • enabled on/off.

Exactly one language is the default — it's the source language and the end of every fallback chain. Adding a language is instant; there's no migration.

#2. Make a field translatable

Edit a field (Data → your collection → Settings → a field) and turn on Translatable. It's available for text, rich text, markdown, code, slug and JSON fields. Numbers, dates, booleans, relations and geo are shared across languages.

The revealed options let you choose:

  • Required in — best practice is default language only, so you can publish before every translation is done. Choosing all languages makes the write fail until every configured language has a value, and the error names the ones that are missing. It applies whenever the field is written, through any endpoint, and it never fails a save that doesn't touch the field — turning it on won't make your existing records unsaveable.
  • AI translation — whether flows and the ✦ Translate button may fill this field;
  • Never translate — for proper nouns or codes that should be identical everywhere.

Unique is not available on a translatable field. The value stored is one map per record holding every language, so "unique" over it would compare whole translation sets rather than the text you mean — satisfied by translating one language differently. Saving a field that's both is refused, with that explanation. If you need uniqueness, keep the field non-translatable and use one record per language.

#3. Translate records

Open a record. A language bar appears with a completeness dot per language.

  • Single view edits one language at a time. Empty fields show the default language as ghost text so nothing ever looks blank.
  • Compare view shows the source language beside a target language, field by field — ideal for translating.
  • ✦ Translate empty fields machine-translates the empty fields for a language. It only ever fills blanks — it never overwrites what you've written.
  • Status chips show where each translation stands: AI draft, Needs review, Human, or Outdated. When you change the source text, the other languages are automatically flagged Outdated so reviewers know to revisit them.

#4. Read translated content via the API

Add ?locale= to any read:

Request You get
(no locale) the default language, resolved
?locale=de-DE German, falling back to the default if empty
?locale=* the whole { locale: value } map (for editors)
GET /api/v1/collections/animals/records/123?locale=de-DE
{ "data": { "id": "123", "title": "Rotes Riesenkänguru" } }

GET /api/v1/collections/animals/records/123?locale=*
{ "data": { "id": "123",
  "title": { "en-AU": "Red kangaroo", "de-DE": "Rotes Riesenkänguru" } } }

A missing translation is never returned as an empty string — it falls back (requested → the language's fallback chain → default). Add ?meta=i18n to a single-record read to also get an _i18n object telling you, for each language, what state its text is in and where it came from:

{ "title": { "de-DE": { "status": "machine", "by": "usr_01JA…", "at": 1718900000 } } }

That answers the question a status alone could not: who wrote this translation, and has anyone looked at it since? by and at follow the text, not the record — a save that only touches English leaves the German authorship exactly where it was, including when English changing marks the German outdated. Records translated before this existed simply have no author recorded.

#5. Write translated content via the API

POST / PATCH accept either shape, controlled by ?locale=:

  • One languagePATCH …?locale=de-DE with a plain value merges into the stored map, leaving other languages untouched:
    { "title": "Rotes Riesenkänguru" }
    
  • All languagesPATCH …?locale=* replaces the whole map:
    { "title": { "en-AU": "Red kangaroo", "de-DE": "Rotes Riesenkänguru" } }
    
  • Simple clients — a plain value with no ?locale= is written under the default language, so POST { "title": "Red kangaroo" } just works.

The same rules hold on every write endpoint — single create and update, bulk create, both halves of the batch endpoint, and CSV/NDJSON import. A plain value always merges into one language and never replaces the map; only sending the map itself replaces it.

#6. Localized choice & field labels

Two label kinds localize the display while the stored value stays stable:

  • Choice options — a grade field can store grade_4 but show "Grade 4 — Hard" / "Stufe 4 — Schwer". Set these under the enum's Option labels & translations in the field editor.
  • Field labels — a field named summary can show "Summary" / "Zusammenfassung" via Label translations.

See Field types for the field-definition properties (translatable, enumOptionLabels, labelI18n).

#7. Sort & filter by a translation

?sortBy= and ?filter= both operate on the active ?locale='s value, so you can query the language you're actually looking at:

// German posts whose title contains "Katze", A→Z by German title
GET /api/v1/collections/posts/records?locale=de-DE
    &filter={"title":{"_contains":"Katze"}}&sortBy=title

All the usual operators apply to a translatable field (_eq, _contains, _starts_with, _in, _empty, …). Two things to keep in mind:

  • No fallback. A record with no value in the requested locale doesn't match a value filter and sorts as empty. Filtering only ever returns rows that actually hold that translation — reads still fall back through the chain, filters deliberately don't, so a query never returns rows the caller didn't ask for.
  • Speed. Mark the field indexed and a per-locale expression index is built automatically on first use, so repeat sorts and filters on that language stay fast.

?search= behaves differently on purpose: it looks across every language, not just the active one, because the record you are hunting for is often the one that hasn't been translated yet. Searching Katze finds a record whose German says "Katze" even from an English session. It matches the translations themselves — never the language codes — so ?search=de-DE no longer returns your whole collection.

#8. Using the SDK

const posts = sdk.collection('posts');

// Read one language, or the whole map:
const de = await posts.list({ locale: 'de-DE' });
const all = await posts.get('rec_123', { locale: '*', meta: 'i18n' });

// Write one language (merges) or all languages (replaces):
await posts.update('rec_123', { title: 'Hallo Welt' }, { locale: 'de-DE' });
await posts.update('rec_123', { title: { 'en-AU': 'Hi', 'de-DE': 'Hallo' } }, { locale: '*' });

#Not yet supported

Independent per-language publishing is on the roadmap. Sorting and filtering compare by byte order (SQLite has no locale-aware collation) — correct and stable, but not language-specific alphabetization (e.g. German ä/ö/ü don't fold next to a/o/u).