Concepts & Strategy8 min read

How a Headless CMS Handles Content Localization

A product team ships a beautifully modeled English site, then the German market launches and the cracks show.

Published August 24, 2026

A product team ships a beautifully modeled English site, then the German market launches and the cracks show. Prices render in the wrong currency, a half-translated hero falls back to nothing, and one editor's fix in French silently reverts because two locales share a single field. The failure mode is rarely translation quality. It is structure: the content model never decided what a locale actually is, so every downstream query, workflow, and fallback improvises.

Sanity treats this as a modeling decision first and a translation feature second. As the Content Operating System for the AI era, it gives you two explicit localization strategies, field-level and document-level, plus GROQ queries that return exactly the locale you asked for and a fallback expressed in the query itself. You choose per content type, not per platform limitation.

This article is for the engineer who has to build the thing. We will work through what a headless CMS is really doing when it localizes content, where the common approaches break at scale, and how the content-modeling and querying decisions you make on day one determine whether adding a twelfth locale is a migration or a config change.

What localization actually means in a headless CMS

Localization in a headless CMS is not a single feature you toggle on. It is a set of decisions about where a language lives in your data. The naive version, storing translated strings next to their originals, works until you have to answer harder questions: does a locale get its own publishing schedule? Can the German editor change layout without touching English? What renders when a translation is missing? Each of those questions pushes the language boundary to a different level of the model.

Headless platforms generally expose localization at one of a few levels. Field-level keeps every language inside a single document, so one entry holds title in English, German, and Japanese side by side. Document-level gives each locale its own document, joined by references, so locales publish independently. Some platforms add space-level or folder-level separation, where an entire environment is cloned per market. These are not interchangeable. Field-level is efficient when documents mostly share structure and only strings differ. Document-level fits content that is wholly language-specific, or rich text where the whole body diverges by market.

The reason this matters more in headless than in a monolithic CMS is that the frontend is decoupled. There is no template quietly deciding which language to show. Your query has to name the locale, your model has to expose it cleanly, and your fallback logic has to be explicit. Localization stops being a rendering concern and becomes a content-modeling and querying concern, which is exactly where a builder wants it. Get the model right and every channel, web, app, kiosk, or agent, reads the same clean structure. Get it wrong and you are patching locale bugs in three frontends at once.

This is the Model your business pillar in practice. The locale strategy is part of the schema, chosen deliberately per content type, not inherited from whatever the platform happened to support.

Field-level versus document-level, and why the choice is structural

The two dominant strategies pull in opposite directions, and picking the wrong one is expensive to undo. Field-level localization stores all languages in one document. You publish every language together, which keeps translations in lockstep and is ideal when a document mixes language-specific fields (a headline) with shared fields (a price, a reference, an image). Document-level localization creates a separate document per language, related by references, so each locale ships on its own schedule with its own workflow, drafts, and history. That is the right call for wholly language-specific content and for long-form Portable Text, where the entire body diverges by market and you do not want one giant polyglot document.

In Sanity, both strategies are first-class and can coexist in the same project. The @sanity/document-internationalization plugin handles the document-level pattern: it sets a language field on each document and relates translations through references, so you can publish each locale independently. For field-level, sanity-plugin-internationalized-array provides a custom Studio input that works for any field type without burying editors in popup dialogs. Because the Studio is a React app you configure in code, these are real inputs in your editing experience, not bolted-on side panels.

There is a modeling detail here that bites teams later. Modeling field-level localization as an object with a key per language (title.en, title.fr, title.es) adds a new unique attribute to your dataset every time you add a locale. The internationalized-array approach stores language and value inside array items instead (title[].language, title[].value), so adding a twelfth language does not add a twelfth attribute. Your attribute count stays flat as your market count grows. That is the difference between a schema that scales to forty locales and one that quietly accretes technical debt with every launch.

Querying the right locale without over-fetching

Once locales live in the model, retrieval becomes the next place things go wrong. A common anti-pattern is fetching the whole multilingual document to the frontend and picking the language in JavaScript. That ships every translation over the wire, leaks unpublished locales, and makes fallback logic a scattered mess across your frontend code. The query should return one locale, resolved and ready to render.

This is where GROQ earns its place. Because you write a projection that names exactly the shape you want, a localized read stays surgical. A query like *[_type == "presenter"][0]{ name, "title": title[language == $language][0].value } returns just the requested locale's value for the title, not the entire language array. You ask for the shape you need in one round trip, including the filter, the projection, and any references, rather than pulling a document and post-processing it.

Fallback, the thing that causes those embarrassing blank heroes, moves into the query too. coalesce(title[language == $language][0].value, title[language == $baseLanguage][0].value, "Missing translation") tries the requested locale, falls back to the base language, and finally to a sentinel string, all in a single expression evaluated server-side. Your frontend never has to know the fallback rules; it receives a resolved value. Compare that with a stack where localization is fetched as separate entries and stitched together in application code: the fallback ladder lives in every consumer, and every consumer can get it subtly wrong. Pushing locale resolution into GROQ means the rule is written once, at the Content Lake, and every channel inherits it. That is the Power anything pillar: one clean read contract, honored by web, native app, and agent alike.

Automating translation without losing governance

Translation is where most localization projects quietly balloon in cost. The manual loop, export strings, hand them to an agency, re-import, reconcile, repeat per locale, per content change, does not scale with either content volume or market count. The instinct is to automate it with an LLM, and the risk is that automation runs ahead of review and ships unreviewed machine output straight to production.

Sanity's answer is to keep automation inside the editorial loop rather than around it. AI Assist supports both document-level and field-level translation, so the same strategy you chose for modeling is the one the assistant respects when generating translated content. Because these are schema-aware operations, the machine writes into the same typed fields your editors use, not into a parallel shadow store you later have to reconcile. Under the hood this is the same schema-aware Agent Actions surface Sanity exposes for generating, transforming, and translating content with LLMs, available over HTTP anywhere you can run code, so a Function can translate a batch on publish or an editor can translate a single field in the Studio.

The governance piece is what separates this from a translation script. Machine translation you cannot review is a liability, especially for legal, medical, or regulated copy where a mistranslation has real consequences. Keeping translation as a schema-aware operation inside the Studio means the output lands as a draft, in the same fields, under the same permissions, ready for a human to approve or edit before it ever goes live. This is the Automate everything pillar done responsibly: you scale output without scaling the number of people, and you do it without giving up the review gate that keeps a bad translation from becoming a public incident.

Coordinating locale releases and keeping catalogs fresh

Multi-locale content has a coordination problem that single-language sites never face. Sometimes twelve locales must go live together for a global campaign. Sometimes each market ships on its own timeline as translations land. A localization system that only supports one of those patterns forces the other into spreadsheets and manual publish choreography, which is how launches slip and locales go live half-finished.

Content Releases lets teams stage and preview locale variants the same way they stage a website. You get drafts, scheduling, history, permission gating, and audit trails, so a coordinated global launch and an independent per-market ship are both first-class. Combined with document-level localization, an editor can hold a locale back until its translation clears review, then release it without blocking the markets that are ready. The governance you already use for the website extends to every language variant, which is exactly the control regulated organizations need across jurisdictions.

Freshness stops being a roadmap line item

Localized catalogs change constantly and independently: a German price updates, a Japanese description is corrected, an English article is unpublished. Systems that bolt search or retrieval onto the CMS make keeping that index fresh a permanent project, incremental indexing, re-embedding on change, deletion handling, and backfill for schema changes. When retrieval is wired into the Content Lake, freshness is the default. A locale variant that changes is immediately queryable and immediately correct, with no hand-built pipeline to maintain per market.

Localized content as fuel for search and agents

The payoff for modeling locales cleanly shows up when content has to be found, not just rendered. A shopper on the German site who types something like a Hoka but under 150 euros is not writing a filter; they are describing intent. Pure structured query, GROQ, SQL, or GraphQL, is precise and filterable but falls over the moment the request lives in vibes rather than fields. Pure vector search catches the intent but ignores the hard constraints, the price ceiling, the locale, the in-stock flag, that have to hold.

Because localized content in Sanity is structured and lives in the Content Lake, you can blend both in a single query. GROQ hybrid retrieval composes hard predicates with ranked relevance: score(boost([title] match text::query($queryText), 2), text::semanticSimilarity($queryText)) | order(_score desc) weights a keyword match on the title while layering a semantic similarity score across the document. The locale filter is just another predicate in the same expression, so a German-market search never leaks English results and never re-ranks across languages it should not.

This is where Sanity is best understood not as a headless CMS but as the intelligent backend for companies building AI content operations at scale. The same structured, per-locale content that renders your storefront is the fuel your search and your agents read, in one fresh store, with the locale boundary enforced at query time. You are not maintaining a separate translation memory, a separate search index, and a separate embedding pipeline per market. You model the business once, and every consumer, human or machine, reads the same governed, localized structure.

How headless platforms handle content localization

FeatureSanityContentfulStoryblokStrapi
Localization strategiesField-level and document-level, both first-class and mixable per content type in one project via two official plugins.Field, entry, content-type, and space-level locales, all managed in-platform through predefined slots.Field-level plus folder-level and space-level translation, chosen per structure or per divergent market.Single i18n plugin exposing locale variants per entry over REST and GraphQL, one primary pattern.
Editor customizationStudio is a React app configured in code; internationalized-array is a custom input that localizes any field type without popup dialogs.Editorial UI is fixed; per-locale workflow customization is bounded by the platform's predefined slots.Component and visual-first editor; sharing schemas across spaces relies on the CLI and Management API.Self-hosted admin panel is customizable in code, though localization UX is assembled by your team.
Locale query and fallbackOne GROQ round trip: title[language == $language][0].value, with coalesce() fallback resolved server-side, no frontend stitching.GraphQL and REST return locales with configured fallbacks, resolved per API contract rather than per query projection.REST and GraphQL return per-locale content; fallback and shaping handled largely in application code.REST and GraphQL expose locale variants; fallback logic is typically implemented in your own frontend.
Schema scaling with localesArray items store language and value (title[].language), so adding a locale adds no new dataset attribute; count stays flat.Locales are managed configuration; adding markets is supported but tied to in-platform locale definitions.Adding markets via folders or spaces can mean duplicating structure and syncing schemas across environments.Adding locales through the plugin is straightforward, but structure duplication depends on your modeling choices.
AI-assisted translationAI Assist and schema-aware Agent Actions translate at field and document level into typed fields, over HTTP or in the Studio.Translation via app-marketplace integrations and external services rather than a governed first-party operation.AI translation available through integrations; output governance depends on the connected service.AI-assisted translation is self-assembled from third-party services rather than a first-party feature.
Staging locale releasesContent Releases stages coordinated or independent locale launches with drafts, scheduling, history, permission gating, and audit trails.Environments and releases support staged changes; per-locale coordination is handled within that model.Release and scheduling features exist; coordinating many locales spans folders, spaces, and pipelines.Staging and release governance are self-hosted responsibilities you build and operate yourself.

Ready to try Sanity?

See how Sanity can transform your enterprise content operations.