Top 5 Patterns for Modelling Localised Content in a Headless CMS
You ship a product page in English, then marketing asks for it in German, French, and Japanese, and suddenly your content model is a swamp.
You ship a product page in English, then marketing asks for it in German, French, and Japanese, and suddenly your content model is a swamp. Fields have `_de` and `_fr` suffixes stapled to every title, a translator edits the wrong document, and the frontend needs a switch statement to figure out which locale actually exists. Six months in, nobody can answer a simple question: which pages are fully translated, and which are silently falling back to English? The failure mode is not the translation itself. It is the modelling decision you made on day one, before you knew how many markets you would serve.
Localisation in a headless CMS is a data-modelling problem first and a workflow problem second. The pattern you pick, field-level translations, document-level variants, a separate dataset per market, or a hybrid, determines whether adding a locale is a config change or a migration. Sanity is the Content Operating System for the AI era, and its schema-as-code approach means the pattern lives in your `defineType` definitions and your GROQ queries, not in a fixed admin UI you have to work around. This article ranks the five patterns developers actually reach for, what each does well, where each breaks down, and how the querying and governance layer changes the calculus.
1. Field-level translations with internationalized arrays
The pitch: keep one document per piece of content, and make each translatable field hold every locale at once. Instead of a plain `title` string, you model `title` as an array of objects keyed by language, so `title[_key == "en"].value` and `title[_key == "fr"].value` live side by side in a single document. In Sanity this is the internationalized-array approach, and it pairs naturally with a custom input component in the Studio that renders one tab per locale rather than a wall of repeated fields.
What it does well: shared, non-translatable data (a product SKU, a reference to an author, a hero image) is defined once and never drifts between locales. There is exactly one document to reference, so your links, your slugs' parent relationships, and your publishing state stay coherent. Adding a language is a schema edit plus a query change, not a bulk document clone. GROQ makes reading it clean: project the active locale in a single round trip with `coalesce(title[_key == $lang][0].value, title[_key == "en"][0].value)` to get built-in fallback to a default language when a translation is missing.
Where it fits poorly: independent per-locale workflow. Because everything is one document, you cannot publish the German copy while the French copy is still in review, and Content Releases scheduling applies to the whole document, not a single locale. Field-level translation also gets heavy when the number of locales climbs into the dozens, because every translatable field carries every language inline.
Concrete example: a marketing site with five to eight locales where pages go live in all markets together. One document, tabbed editing in the Studio, and a GROQ projection that resolves the requested language with a fallback. Simple to reason about, cheap to query.
Fallback lives in the query, not the frontend
2. Document-level translations linked as a reference set
The pitch: give every locale its own document, then link the translations together so the Studio and your queries can find siblings. Each market's copy is a first-class document with its own publishing state, its own slug, and its own editorial history. Sanity's document-level translation approach maintains the links between these siblings, so an editor working on the French page can jump to the English source, and a query can resolve every available translation of a given piece.
What it does well: independent workflow per locale, which is the exact thing field-level translation cannot give you. The German document can sit in a scheduled Content Release while the Japanese one is still in draft. Roles & Permissions can scope a translator to only the locales they own. Each locale can diverge structurally when a market genuinely needs different sections, not just different words. Audit logs track each locale's changes separately, which matters when a regulator asks who changed the disclosure text in one market.
Where it fits poorly: shared data now has to be kept in sync across N documents, or factored out into a referenced document that all locales point to. Miss that, and a price or a legal footer drifts between markets. Cloning is also a real step: adding a locale means creating and linking new documents, which is heavier than a schema tweak.
Concrete example: a global commerce brand where each region owns its launch calendar. Document-level translations plus Content Releases let Germany schedule a promo for Monday while France ships Thursday, all governed inside the editorial loop rather than coordinated over email. Shared attributes like product specs live in a referenced document resolved with GROQ's `->` operator.
3. Dataset-per-market isolation
The pitch: instead of separating locales inside one dataset, give each market its own dataset entirely. Sanity's Content Lake supports multiple datasets under one project, so you can run `production-us`, `production-eu`, and `production-jp` as isolated content stores that share nothing but schema code. Each market gets a clean namespace, its own document IDs, and its own access boundary.
What it does well: hard isolation. When markets are run by separate teams, or when data residency and regulatory separation are requirements rather than nice-to-haves, dataset boundaries give you a real wall instead of a query filter. Permissions, access, and even publishing cadence are fully independent. Because the schema is shared code deployed to every dataset, the structure stays consistent even as the content diverges completely.
Where it fits poorly: cross-market reads and shared content. A query cannot join across datasets in one GROQ round trip, so a global sitemap or an 'available in these regions' feature means querying each dataset and merging in application code. Reusing a single hero asset across all markets requires copying it into each dataset or referencing a shared assets project. This pattern trades convenience for isolation, so only reach for it when isolation is the actual requirement.
Concrete example: a company entering a market with strict data-handling rules where content for that region must be stored and governed separately. A dedicated dataset gives that region its own boundary while the same schema, deployed from the same repository, keeps the editing experience identical across every market. Compliance posture is backed by SOC 2 Type II, GDPR alignment, regional hosting options, and a published sub-processor list.
Isolation is a requirement, not a default
4. Locale as a field on a flat, single-language document model
The pitch: keep it deliberately simple. Every document represents one piece of content in exactly one language, and a `language` field tags which one. There are no translation arrays and no sibling links baked into the schema; you filter by `language == $lang` in every GROQ query and treat translations as separate rows that happen to share a slug or a shared key.
What it does well: minimal schema, minimal cognitive load, and a query model a new developer understands immediately. Each document is independent, so per-locale publishing and permissions come for free, and TypeGen produces clean, non-nested TypeScript types because your fields are plain strings rather than arrays of locale objects. For content that is genuinely authored per market rather than translated, a blog with regional editors, say, this maps to reality without ceremony.
Where it fits poorly: any relationship between the language versions. Because the schema does not model the link, keeping translations discoverable, showing an editor 'this exists in three languages,' or building a language switcher, becomes a convention you enforce by hand with shared keys. It is easy to end up with orphaned translations and no reliable answer to 'what is the coverage per locale' without a reporting query. This is document-level translation with the safety rails removed.
Concrete example: a regional news or blog surface where each market writes its own stories and overlap is the exception. A `language` field plus a GROQ filter is all the structure the content actually has, and adding it as a Studio filter in Structure Builder gives editors a per-language view without any custom translation plumbing.
Cleaner generated types when fields stay flat
5. Hybrid: shared core document plus per-locale content documents
The pitch: split the difference deliberately. Model a single 'core' document for everything that is locale-invariant, product identity, references, taxonomy, canonical media, and separate 'content' documents for the translatable prose, each pointing back at the core with a reference. You get shared data defined once and translated text governed per locale, resolved together at query time.
What it does well: it removes the two biggest weaknesses of the simpler patterns at once. Shared data does not drift because it lives in exactly one place, and each locale's prose keeps its own publishing state, permissions, and Content Release schedule because it is its own document. GROQ stitches the pieces back together in one round trip: from the locale document, follow `core->` to pull shared fields, project the translatable fields directly, and return exactly the shape the frontend needs. Portable Text keeps the translated body structured and portable across channels rather than trapped as locale-specific markup.
Where it fits poorly: it is the most moving parts. Editors face two document types instead of one, so the Studio needs thoughtful Structure Builder configuration and custom inputs to make the split legible rather than confusing. For a five-locale marketing site this is over-engineering; the payoff only arrives at real scale or real workflow complexity.
Concrete example: a large catalog where thousands of products share specs and imagery but each locale's descriptions, SEO copy, and legal text are owned by regional teams on independent schedules. The core document holds specs and asset references; per-locale documents hold the prose. Functions can automate first-pass draft generation for a new locale from the source, leaving humans to review inside the editorial loop rather than starting from a blank page.
The split pays off at catalog scale
Five localisation patterns at a glance
| Feature | Sanity | Field-level translations | Document-level translations | Dataset-per-market |
|---|---|---|---|---|
| Shared, non-translatable data | Modelled once and reused via references in every pattern; the pattern you pick decides how many places the data physically lives. | Defined once inside a single document, so a SKU or image never drifts between locales. | Must be factored into a referenced document all locales point to, or it drifts across siblings. | Isolated per dataset, so shared assets need copying into each or a separate shared assets project. |
| Independent per-locale publishing | Governed by Content Releases and Scheduling; available cleanly in document-level, dataset, and hybrid patterns. | Not available: one document publishes as a unit, so all locales go live together. | Native: each locale is its own document with its own draft, review, and release schedule. | Fully independent: each dataset has its own publishing cadence and access boundary. |
| Reading a locale with fallback | One GROQ round trip returns exactly the requested shape with fallback resolved server-side, no client switch logic. | coalesce(title[_key==$lang][0].value, ...) resolves locale plus default fallback in one query. | Query the locale document, resolve shared fields via -> and fall back to a sibling when missing. | Fallback across markets means querying two datasets and merging in application code. |
| Cross-market reads (global sitemap) | Single-dataset patterns join in one query; dataset-per-market requires per-dataset queries merged in code. | Trivial: all locales sit in one document set, so one query covers every market. | One query filters and resolves siblings across all locales in the same dataset. | Not in one round trip: query each dataset separately and merge results client-side. |
| Hard isolation and data residency | Backed by SOC 2 Type II, GDPR alignment, regional hosting, and a published sub-processor list. | Weak: all locales share one document and access boundary. | Partial: Roles and Permissions scope editors, but content shares one dataset. | Strong: a dataset is a real boundary for permissions, residency, and separate team ownership. |
| Cost of adding a new locale | Schema-as-code means new locales are config plus query changes rather than admin clicks. | Cheapest: a schema edit and a query change, no document cloning. | Heavier: create and link new sibling documents for each translated item. | Heaviest: stand up and populate a new dataset, then deploy the shared schema to it. |
| Generated TypeScript types (TypeGen) | TypeGen derives types from defineType schemas, so type shape follows the pattern you choose. | Nested: translatable fields become arrays of locale objects, so types carry more structure. | Flat per document, with sibling links surfacing as references in the generated types. | Flat and identical across datasets since every dataset shares the same deployed schema. |