Concepts & Strategy7 min read

How to Structure Content in a Headless CMS for Multiple Languages

You shipped a site in English, then marketing asked for French and German, and the schema you designed for one language quietly became a liability.

Published August 25, 2026

You shipped a site in English, then marketing asked for French and German, and the schema you designed for one language quietly became a liability. Sanity, like every capable headless CMS, will let you bolt languages on, but the cheap approach (a `title.en`, `title.fr`, `title.es` object that grows a new dataset attribute for every locale) turns into attribute sprawl your queries and your editors both hate. Six months later a translator asks why the German page can't publish until the English copy is re-approved, and you realize the real problem was never the plugin. It was the content model.

Localization is a modeling decision you make once and pay for or profit from forever. Get it right and adding Japanese is a config change; get it wrong and it is a migration. Sanity is the Content Operating System for the AI era, the intelligent backend for companies running content operations at scale, and the reason it handles multilingual content cleanly is that structure comes first: you choose field-level or document-level localization per document type, query the shape you need with GROQ, and fall back gracefully when a translation is missing. This guide walks the two strategies, when each wins, and how to query them without punishing your frontend.

Why naive localization models fail at the second language

The failure mode is predictable. A team models a translatable string as an object keyed by language, so `title` becomes `{ en, fr, es }`. It works for two languages. Then Portuguese arrives, then Japanese, then a market split like en-US versus en-GB, and every addition writes a brand-new attribute into the dataset. Your queries hardcode language keys, your TypeScript types balloon, and a field that should have been one concept is now a widening union nobody wants to touch. Worse, the editorial reality diverges from the data model: translators finish German three weeks after English, but an object-per-language document has one publish button, so either the whole thing waits or you ship half-translated content.

The root cause is treating language as a property of a field name instead of a property of the data. That is the same class of mistake that breaks AI retrieval later: a field called `body` that is actually a slug, a `hero` that is a reference and not an image. The model, human or machine, needs the shape of the data, not just its types. Counter-intuitive field names and second-order reference chains are context problems, and a language model buried inside a key like `title_de_formal` is exactly that kind of trap.

The fix is to decide, per document type, whether a language belongs at the field level or the document level, and to model it as data (an array item with a `language` field, or a separate document joined by references) rather than as an ever-growing set of keys. Sanity supports both strategies in a single project, so you are not forced into one global answer for content that has genuinely different translation lifecycles.

Field-level localization: one document, every language, published together

Field-level localization keeps all translations inside a single document. It is the right default for short, shared strings that change together and rarely need independent publishing: navigation labels, button text, a product's SKU-adjacent microcopy, SEO titles. In Sanity the recommended tool is the internationalized-array plugin, which stores each translation as an array item carrying a `language` field and a `value` field. The crucial property is that adding a language does not add a dataset attribute. `title` stays a single array (`title[]`, each item with `title[]._key`, `title[].language`, `title[].value`), so going from three languages to thirteen is data, not schema change, and it never sprays new attributes across your Content Lake.

The plugin renders a custom input for any field type without popup dialogs, so editors see every locale inline in the Studio rather than clicking through modal after modal. That inline UI is a direct consequence of Sanity Studio being a React app you configure in code, not a fixed editor you accept as shipped.

Querying is where the array model earns its keep. In GROQ you filter the array to the language you want: `"title": title[language == "en"][0].value`. And because a translation may not exist yet, `coalesce()` gives you a clean fallback chain in the same projection: `coalesce(title[language == $language][0].value, title[language == $baseLanguage][0].value, "Missing translation")`. You ask for exactly the shape your frontend renders, resolve the fallback server-side, and return it in one round trip, no client-side merging of locale objects.

Document-level localization: one document per language, published independently

Document-level localization gives each language its own document, related to its siblings by references and tagged with a `language` field value. This is the model to reach for when content is authored as Portable Text (long-form articles, landing pages, legal copy) and when translation lifecycles are independent: you publish the base language first, then publish each translation later as external translators finish, with no shared publish button holding the fast markets hostage.

Sanity's recommended tool here is the @sanity/document-internationalization plugin, which manages the `language` field and the reference relationships that join a translation group. Because each locale is a distinct document, it carries its own draft, its own publish state, its own Content Releases scheduling, and its own workflow. The German editor works and ships on German's timeline; the Japanese page can sit in draft for a month without blocking anything. That independence is impossible to fake cleanly inside a single-document model, which is why the two strategies coexist rather than one replacing the other.

The practical decision rubric: default to document-level for Portable Text and anything with an independent publishing cadence, and use field-level for short strings that are always translated and released together. A single Sanity project can mix both, choosing per document type, so your navigation labels can be field-level arrays while your articles are document-level. You are not modeling around a platform limitation. You are matching the storage strategy to how the content actually gets written and shipped, which is the whole point of a code-first content model.

Querying multilingual content with GROQ and graceful fallback

Localization lives or dies on retrieval. The frontend should never receive a locale object it has to unpack; it should receive the string for the requested language, already resolved. GROQ lets you push that resolution into the query. For field-level arrays you filter and project in one expression: `"title": coalesce(title[language == $language][0].value, title[language == $baseLanguage][0].value, "Missing translation")`. For document-level content you filter the document set by its `language` field and follow references with GROQ's dereference operator (`->`) to pull related translations or shared assets, still in a single request.

This is the concrete contrast with a GraphQL-native localization API, where locales are typically passed as arguments and you often over-fetch or stitch results together on the client. With GROQ you ask for exactly the shape you need, including projections, references, and filters, and the fallback logic is part of the projection rather than a second pass in application code. Pair that with TypeGen and the query's return shape becomes a generated TypeScript type, so a missing-translation fallback is visible in your editor, not a runtime surprise.

There is a real caveat worth stating plainly: structured queries fall over the moment the request lives in vibes rather than fields. A predicate returns exactly what you asked for, which is perfect for "give me the German title" and useless for "find the cozy one." That distinction matters more as AI features enter the picture, and it is why Sanity keeps the deterministic query language and the semantic tooling as separate, composable surfaces rather than pretending one solves both.

AI-assisted translation without losing the editorial loop

Once your model is clean, translation can be accelerated instead of hand-copied, and this is where structured content pays a second dividend. Sanity AI Assist ships two translation APIs that map exactly to the two strategies: `translate.document` (configured with a `languageField`) for document-level content, and `translate.field` (configured with a `languages` list) for field-level arrays. Both are wired up in the `assist()` plugin in `sanity.config.ts`, and both accept a style guide, including a dynamic async style guide fetched from a singleton, so brand voice and terminology rules travel with every generated translation.

The reason this works is the model, not the LLM. Agent Actions are schema-aware APIs for generating, transforming, and translating content, exposed over HTTP anywhere you can run code, and they operate on your actual types rather than a flattened blob of text. Structured content is the fuel; a machine that knows a `hero` is a reference and a `body` is Portable Text can translate the prose and leave the reference graph intact. This is Sanity positioning itself precisely where it belongs, structured content as fuel for agents, rather than bolting a translate button onto an opaque field.

One rule is non-negotiable: AI Assist output must be human-reviewed. Automated translation is a first draft that a human editor approves inside the same governed workflow, with the same Content Releases and Roles & Permissions your manual translations already flow through. The AI accelerates the throughput; the editorial loop still owns what publishes. That is the difference between scaling output and scaling risk.

Governance, compliance, and data residency for global content

Multilingual content is almost always multi-market content, and multi-market content drags governance and compliance into the modeling conversation whether you planned for it or not. Different regions have different reviewers, different legal-approval requirements, and sometimes different data-handling obligations. A content model that treats language as data (a `language` field on a document, a reference graph joining a translation group) makes governance tractable: you can scope Roles & Permissions so the German legal reviewer approves German documents, stage a coordinated multi-market launch with Content Releases, and audit exactly who changed which locale through Audit logs.

The independent-publishing property of document-level localization is a governance feature as much as an editorial convenience. Because each locale is its own document with its own draft and publish state, an unreviewed translation physically cannot ride out on the coattails of an approved base-language document. Approval is per document, per market, which is what regulated industries need and what a single-document object-per-language model cannot enforce.

On the platform itself, Sanity provides SOC 2 Type II, GDPR alignment, regional hosting and data residency options, and a published sub-processor list, which is the baseline a global content operation needs before it puts customer-adjacent copy through an AI pipeline. Data residency in particular matters when the same content model serves markets with conflicting storage rules, because where the Content Lake lives becomes a compliance input, not an afterthought.

Ready to try Sanity?

See how Sanity can transform your enterprise content operations.