Translation was in the schema before it was a feature

Each language of an entry is a full database row, not a column suffix or a side table. Siblings share an identity, each field is either shared across all of them or written per language, and a missing language falls back down a chain you configure.

For teams selling in more than one market

One row per language, sharing an identity

Marking a content type as translated adds two columns: the locale, and a group id shared by every language of the same record. There is a unique index across the pair, so a record cannot accidentally grow two German rows.

Each language row is a complete entry. It has its own status, its own published date and its own version history, which is what lets the German page be live while the English one is still being written — a thing that is awkward in every model where translations are fields on one row.

Per field, you mark what varies. An unmarked field is shared: one value across the whole group, so a price or a product code is written once and is identical everywhere by construction rather than by discipline. A field marked as localized is written per language.

A coverage grid of four content types against four languages. Articles and products are translated widely; landing pages reach German only, and authors exist in English alone.
Content type EnglishGermanFrenchJapanese
Article Yes Yes Yes draft
Product Yes Yes Yes Yes
Landing page Yes Yes No No
Author Yes No No No
Each filled cell is a row of its own, with its own status. The Japanese article is a draft while the other three are live.

What happens when you edit a shared field

Changing a shared value has to reach every sibling, and doing that carelessly is how a live translation silently becomes wrong. So the save does three things in one transaction: it copies the new value to every sibling, it moves any published sibling back to draft while keeping its published date — so it reads as Modified rather than as never-published — and it re-validates every sibling that was live.

If that re-validation fails, the whole save is rolled back. A draft edit in one language cannot leave a published page in another language invalid.

Relations follow the same idea with more care. A link belongs to the record rather than to the language, so assigning it in one place assigns it everywhere by default. Where a link points at something that is itself translated, each sibling gets the target in its own language — and if that target has no translation yet, the link is left unset rather than pointing at the wrong language’s row. An English save is never blocked because a German tag does not exist.

The rules in one place

Behaviour What it means
Shared field The default. One value for the whole record, synced to every sibling on save.
Localized field Written per language. Marked on the field; rejected at define time on a type that is not translated.
Fallback chain A requested language falls back down a configured chain, ending at the default locale.
Strict listing A list of entries in French shows French rows only; untranslated records are hidden rather than shown in another language.
Widened listing Ask for it and the list widens to "French, or the default-language row of a record that has no French" — what the relation picker uses.
Required to publish Which languages a record needs before it can go live is a configured rule rather than an informal one.

Starting a new language

A new sibling inherits the record
Creating a translation fills its links from an existing sibling — the default language’s row where there is one — so a new French article starts with the right author and the right tags rather than empty.
A sibling nobody changed is left alone
If a sync would produce exactly what a sibling already has, it is not written. That is what stops one save re-versioning all six languages and filling six timelines with noise.
The assistant drafts into the model
Ask it to translate an entry and it proposes into the sibling row for that language, one entry or a batch. It drafts; a person reviews and publishes.
Coverage is on the dashboard
A translation coverage panel breaks the gap down by language or by content type, from one payload — because "which language is behind" and "which type is the work in" get asked a moment apart.

A price change

The product costs €40 now instead of €35. You change it once, on one row. It is a shared field, so all six language rows carry the new number in the same transaction — and each published one drops to Modified, so nobody is left looking at a page that claims to be live while showing the old price. Six pages get re-checked; if any would break, none of it happens.

Questions from teams already doing this badly

Can I add a language later?

Yes. Locales are configuration rather than schema, so adding one does not migrate anything. Existing records simply have no row in it until somebody creates one.

What does the API return for a missing language?

By default, nothing — a strict listing hides records with no row in that language. Ask for the fallback and you get the default language’s row instead, which is usually what a website wants.

Do translations need their own review?

Each is a separate row with its own publish state, so yes in the sense that each is published on its own — and a protection rule, where one is in force, is met language by language for the same reason: approvals are bound to a version, and versions are per locale. Approving the German entry says nothing about the French one.

Is right-to-left supported?

Content is stored and served with its language marker, and a field can declare a language different from the entry’s own. How your front end renders it is your front end’s decision.

Every feature is included, free

The core is MIT licensed and every feature is in it — none of them is paid-only. Community runs free in production; a plan buys room and governance, not a different product.