Locales and translation
Publish to every market you serve from the content you already have. In Content, Corte's headless CMS, you mark individual fields Localized and they hold a value per language, while everything else — prices, dates, references, the images that work everywhere — stays shared, so one edit updates every language at once. A document stays one document: one draft, one publish, one revision history. Start a language whenever you like and readers see your best available copy from the moment you do, because a field still being translated falls back to one that is ready.
This page covers the whole loop: turning locales on, marking fields, editing and translating, what publish requires, and how an application asks for a language.
Enabling locales
Locales are a project setting. A project starts with one locale, English
(en), and nothing about it changes until you add another.
Open Content → Structure → Translation in the studio, or use the API
(PUT /v1/projects/:projectId/content/settings, any project member) or the
update_content_settings MCP tool. Each locale has:
| Field | Meaning |
|---|---|
code | BCP-47-shaped: en, de, pt-BR, zh-Hant-TW. It is the value you pass to the delivery API. |
title | What editors see — "German", "Portuguese (Brazil)". |
fallback | The locale a reader gets when this one is empty. null means the default locale. |
required | Whether a required localized field must be filled in this locale before publish. |
One locale is the default locale: what editors write first, the end of every fallback chain, and what a delivery request reads when it names no locale. Changing it later is allowed; every document keeps its values, they simply read in a new order.
{
"defaultLocale": "en",
"locales": [
{ "code": "en", "title": "English", "fallback": null, "required": true },
{ "code": "de", "title": "German", "fallback": null, "required": true },
{ "code": "fr", "title": "French", "fallback": "de", "required": false },
{ "code": "pt-BR", "title": "Portuguese (Brazil)", "fallback": null, "required": false }
],
"translation": { "brief": "", "autoTranslateOnPublish": false }
}
A locale may not fall back to itself, to a locale that is not in the list, or
through a loop — the settings are refused with invalid_params rather than
silently untangled. Removing a locale hides it; the values documents already
hold stay until each document is next saved.
Marking fields as localized
In a content type, set Localized on a field (localized: true in the
schema). Only fields that carry prose or media can be localized:
string,text,portableText(rich text),media
Numbers, booleans, dates, references, embeds and lists cannot — they are facts or structure, not language. To localize the items of a list, localize the fields of the object the list holds. A localized field's value becomes a map keyed by locale:
{
"headline": { "en": "Spring collection", "de": "Frühjahrskollektion" },
"price": 49,
"hero": { "en": { "storageId": "…" } }
}
A bare value on a localized field — one written before the field was localized, or sent without a map by a script — counts as the default locale's. Flipping the flag on an existing field therefore never invalidates the documents that already use it: they read, validate, filter and publish as English until someone adds another language.
Editing in the studio
A document whose type has localized fields gets a locale switcher at the top of the editor. Pick a locale and every localized field shows that locale's value; shared fields look the same whichever locale is selected. Switching locales never loses unsaved typing — it is one CRDT document underneath, and two people can translate the same document into two languages at once.
Beside each locale in the switcher, and on each row of the document list, a coverage glyph summarizes how filled that locale is:
| State | Meaning |
|---|---|
| Complete | Every localized field has a value in this locale. |
| Partial | Some fields are still empty in this locale. |
| Outdated | Everything is filled, but at least one translation predates the current source text. |
| Empty | Nothing has been written in this locale. |
Values that came from machine translation carry a small machine marker until someone edits them by hand; a hand edit turns them into a human translation and the marker goes away.
The document list can be narrowed to what still needs work: missing in <locale> shows the documents with an empty localized field in that language, and search on the title field finds a document by its title in any language.
Fallback on the live site
When an application asks for a locale, each localized field is resolved along
that locale's chain: the locale itself, then its fallback, then that one's
fallback, and finally the default locale. With the settings above, a French
reader of a page with no French copy gets the German, and failing that the
English.
Every delivered document says what happened. _locale is the locale it was
resolved to, and _fallbackFields lists the localized field paths that had no
value in that locale — served from a fallback, or from nothing at all — so a
site can render a "not yet available in French" note instead of quietly
showing German.
Translating
There are three ways to fill a locale, and they mix freely.
By hand. Switch to the locale and type. Hand-written translations are recorded as such and are never overwritten by the machine unless you explicitly ask for that.
Translate this document. From the editor's locale menu — or
POST /v1/content/documents/:id/translate, or the
translate_content_document MCP tool — machine-translate the document's
localized fields from the default (source) locale into one or more target
locales. You can limit it to specific fields. Rich text keeps its formatting:
bold, links and headings land on the translated words. Media fields are
skipped (there is nothing to translate); they are reported back as skipped
so you can pick a localized image yourself.
The request's mode decides what is rewritten:
mode | Rewrites |
|---|---|
missing | Empty values only. |
outdated (default) | Empty values, plus machine translations whose source text has changed since they were made. |
all | Everything, including hand-written translations. Ask before using it. |
Automatically after publish. With Auto-translate on publish turned on
in the settings, every publish runs the outdated job for the locales that lag
behind. The results land in the draft — the document's status becomes
changed — and nothing is republished until someone looks at it.
Everything you already wrote. Adding a language reaches a document when you next publish it, which on an established catalogue means opening a few hundred of them. Review cost, under Existing documents in Content → Structure → Translation, does the whole project in one go.
It counts and prices the work before it starts — how many documents, how many values, in which languages, and roughly what it will cost — because the credits come out of the project owner's balance, who may not be the person clicking. The estimate is priced at the model you chose above, so switching models changes the figure; with Auto it prices each language at the model Auto would send it to. Then it works through the catalogue one document at a time, filling only what is missing: translations you wrote or corrected by hand are never touched. Every result lands in that document's draft, so nothing changes on your live site until someone reads it and publishes.
Progress shows in the same panel, and Stop ends the run after the document in flight — what it finished is already in the drafts. One run at a time per project, and anyone on the project can start or stop it.
Over the API: POST /v1/projects/:projectId/content/translation-jobs/preview
for the estimate, POST …/translation-jobs to start, GET …/translation-jobs/latest for progress, and POST /v1/content/translation-jobs/:id/cancel to stop.
Choosing a model
Model in the translation settings decides which model writes the translations. It sits beside the tone of voice because it is the same kind of decision — how your German should read — and it is made once for the project rather than per document.
| Option | When to pick it |
|---|---|
| Auto (default) | Almost always. A model is chosen to suit the target language, and the choice improves over time without you changing anything. |
| Claude Sonnet 5 | When your tone of voice is doing real work — a specific register, a glossary, formality rules — and you want it followed closely. |
| GPT-5.6 Luna | When you are translating a lot and want it fast and inexpensive. |
The pane shows an estimate in credits per 1,000 words beneath the picker, and the floor that goes with it: every translation costs at least one credit per language, so for a short document that floor is the whole price.
A single run can override the project's choice — model on
POST /v1/content/documents/:id/translate, or on the
translate_content_document MCP tool — for the one page that deserves the
better model. The response's model always reports what actually ran.
Tone of voice
Tone of voice in the settings rides along with every machine translation, so the output reads like your German rather than a dictionary's. Describe the voice you want ("informal du"), spell out the terms that have a set translation, and list the product names and phrases that should stay in English. Up to 4,000 characters, and worth revisiting the first time a translation comes back sounding slightly off — it is the fastest lever you have on quality.
What "outdated" means
Each translation remembers a fingerprint of the source text it was made from.
When the source changes and the translation does not, the translation is
outdated — a fact, not a guess — and the locale's coverage says so. An
outdated machine translation is redone by the next outdated run (or by
auto-translate on publish); an outdated hand translation is flagged for a
person and left alone.
Credits
Machine translation costs credits — one model call per target locale, priced by the text's size. In a shared project the charge goes to the project owner and the ledger names the member who asked for it. Translating nothing (every target already filled) costs nothing.
The publish gate
Drafts save whatever their state, as always. Publish requires every
required localized field to be filled in the default locale and in each locale
marked required in the settings; other locales may stay empty and fall
back. The refusal names the locale — title.de: Title is required in German —
and the same issue shows inline in the editor under that locale.
Reading locales from the API
A delivery request without locale reads the default locale and returns flat
values — a single-language application needs no change. To read another
language, pass its code; to get every language at once, pass * and each
localized field arrives as its raw map (_locale is null).
curl -G https://api.corte.so/v1/content/$PROJECT/documents \
-H "Authorization: Bearer corte_ct_…" \
--data-urlencode 'type=blogPost' \
--data-urlencode 'locale=de' \
--data-urlencode 'where={"headline":{"contains":"kollektion"}}' \
--data-urlencode 'sort=headline'
{
"documents": [
{
"_id": "…",
"_type": "blogPost",
"_locale": "de",
"_fallbackFields": ["summary"],
"headline": "Frühjahrskollektion",
"summary": "Our spring picks.",
"price": 49
}
],
"total": 1
}
where and sort on a localized field compare the text the requested locale
would read, fallback included — a document with only English matches a German
query through its English. Expanded references come back in the same locale.
GET /v1/content/:projectId/types also returns the project's locales, so a
typed client can generate its own Locale type.
In GraphQL the project's locales are a Locale enum (EN, DE, PT_BR).
The root locale argument sets the locale for the whole query — filters,
sorts, nested objects, embedded values and referenced documents follow it —
and every localized field takes its own locale, so one query can read several
languages side by side:
{
allBlogPost(locale: DE, where: { headline: { contains: "kollektion" } }, sort: [headline_ASC]) {
total
items {
_id
_locale
_fallbackFields
headline
en: headline(locale: EN)
author { name bio }
}
}
}
An unknown locale is refused with invalid_params (REST) or a GraphQL
validation error rather than falling back on its own.
Webhooks
A document.publish webhook carries the document with its raw locale maps and
a locales array: the codes whose delivered content changed. A German-only
edit names de (and any locale that falls back to it); a shared-field edit or
a first publish names every locale. A site can revalidate /de/… alone.
Related
- Content — types, documents, delivery, webhooks
- MCP tools —
get_content_settings,update_content_settings,translate_content_document - Credits explained — how translation is billed