Skip to main content

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:

FieldMeaning
codeBCP-47-shaped: en, de, pt-BR, zh-Hant-TW. It is the value you pass to the delivery API.
titleWhat editors see — "German", "Portuguese (Brazil)".
fallbackThe locale a reader gets when this one is empty. null means the default locale.
requiredWhether 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:

StateMeaning
CompleteEvery localized field has a value in this locale.
PartialSome fields are still empty in this locale.
OutdatedEverything is filled, but at least one translation predates the current source text.
EmptyNothing 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:

modeRewrites
missingEmpty values only.
outdated (default)Empty values, plus machine translations whose source text has changed since they were made.
allEverything, 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.

OptionWhen 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 5When your tone of voice is doing real work — a specific register, a glossary, formality rules — and you want it followed closely.
GPT-5.6 LunaWhen 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.

  • Content — types, documents, delivery, webhooks
  • MCP toolsget_content_settings, update_content_settings, translate_content_document
  • Credits explained — how translation is billed