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, orzh-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 language —
PATCH …?locale=de-DEwith a plain value merges into the stored map, leaving other languages untouched:{ "title": "Rotes Riesenkänguru" } - All languages —
PATCH …?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, soPOST { "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
gradefield can storegrade_4but 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
summarycan 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).