An event has a date, a venue, a speaker — and a title in German and English. In many systems that becomes one entry per language, and the date is stored twice until someone corrects only one copy. RustyCMS localizes per field: date, venue and speaker exist once; only the title carries a language map.
A flag on the field, not a folder per language
// types/page.json5
fields: {
headline: { type: "string", localized: true, required: true },
excerpt: { type: "string", localized: true },
publishedAt: { type: "datetime" }, // shared
author: { type: "reference", collection: "person" }, // shared
}
Storage is one entry per slug. Localized fields inside it are a map { de: "…", en: "…" }; everything else is stored once. No de/ or en/ folder, no question which copy is the source of truth. Localize only what differs by language — dates, references and layout switches stay shared.
Reading: ask for one language, done
GET /api/content/page/home?_locale=en # headline: "Home"
GET /api/content/page/home?_locale=de # headline: "Startseite"
The API unwraps the map and returns flat values; the frontend never sees language maps. If a branch is missing, the space’s default language steps in — a half-translated page has no holes. Resolved references are unwrapped too: an English page gets its author in English without the consumer doing anything.
Writing: touch only your own branch
The translator edits the English teaser and must not overwrite the German one. Add ?_locale=en&_locale_partial=true to the PUT: the server replaces only the en branch and leaves de alone. Sending the full map still works. In the admin UI you switch languages inside the form and edit one at a time.
Validation knows about branches
required is satisfied as soon as one language branch is filled — a draft may exist in German only for now. pattern, minLength, min/max apply per branch. unique is checked per language; a conflict is reported as title[de], not as a vague “duplicate title”. The reference index tells languages apart as well: renaming the German target only affects German content.
Languages per space
RUSTYCMS_LOCALES=de,en sets the global list; the first entry is the default. Every space can deviate — one client site adds fr, another runs de only — through the space settings in the admin UI or via the API. The admin UI also warns about stale language folders left on disk.
Moving from folder-per-language
If you used to keep de/ and en/ as separate directories, run the migration: a dry run shows what would happen, --write merges the files per slug. Old language folders are archived, not deleted. If a non-localized field disagrees between languages, you get a warning instead of a silent decision.