How to Build a Localised Site on a Headless CMS
You ship a site to three markets, and within a week the German editors are blocked: they can't publish a price change because the field is locked to the English source, the French team has quietly forked the content model in a spreadsheet,…
You ship a site to three markets, and within a week the German editors are blocked: they can't publish a price change because the field is locked to the English source, the French team has quietly forked the content model in a spreadsheet, and a "translated" page in Japanese is rendering raw English fallback because nobody agreed on what a missing translation should do. None of this is a translation problem. It's a content-modelling and architecture problem wearing a translation costume.
Localisation breaks on headless stacks not because the strings are hard to translate, but because most teams bolt locale handling onto a model that was designed for a single market. The decisions that actually matter, field-level versus document-level locales, how references resolve across languages, how fallback behaves, how editors see what's missing, get made by accident, in the first sprint, and then calcify.
This guide treats localisation as an architecture decision you make on purpose. We'll work through the modelling choices, the query and fallback mechanics, the editorial workflow, and the operational reality of running many locales at once, using Sanity as the worked example where its modelling and query surface make a strategy concrete.
The decision that comes before translation: field-level vs document-level locales
The first fork in the road has nothing to do with which translation vendor you pick. It's whether a localised value lives as a field inside one document, or as a whole separate document per language. Both are legitimate; choosing without understanding the trade-off is where teams get hurt.
Field-level localisation keeps every language for a piece of content in a single document, a `title` becomes an object with `en`, `de`, `fr` keys, or a localised `string` type. This is excellent when languages share structure and you want a single source of truth: change the publish date once, every locale inherits it. It struggles when markets genuinely diverge, when the German site needs an extra legal section the English one doesn't, or when one market's editor should be able to unpublish independently of the others.
Document-level localisation gives each language its own document, linked by a shared identifier and a `language` field. Markets can diverge structurally, publish on their own schedule, and have their own workflow state. The cost is duplication: shared fields (a product SKU, a hero image) now live in N places unless you reference them out to a single canonical document.
Most real sites end up hybrid: document-level for pages and articles that diverge per market, field-level for atomic shared strings like UI labels and taxonomy. In Sanity you express both with the same primitive, a `defineType` schema with either localised object fields or a `language` field plus a document-internationalisation convention, so the choice is a modelling decision you encode in `sanity.config.ts`, not a platform limitation you inherit. The point is to make the choice deliberately, document it, and resist mixing the two arbitrarily across the same content type.
Modelling references and shared assets so you localise once, not N times
The hidden tax in any multi-locale build is shared data: the things that should be identical across every market and the things that subtly shouldn't. A product's dimensions are the same in every language. Its marketing copy isn't. A category taxonomy might be structurally identical but need translated labels. Getting this wrong means editors maintaining the same number in nine places and drifting out of sync within a quarter.
The discipline is to push genuinely shared, non-linguistic data into canonical documents and reference into them, rather than copying values into every localised document. Linguistic content gets localised; structural facts get referenced. This keeps your single source of truth single, and it means a price or a spec change propagates without a translation cycle.
Where references cross locale boundaries, you need a query language that can resolve them in the same breath as the locale-specific fields, or you pay for it in round trips and glue code. This is where GROQ earns its place: the dereference operator `->` follows a reference inside the same query, so a localised article document can pull its shared product spec, its translated category label, and its market-specific hero in a single projection. You ask for exactly the shape the page needs, localised fields, dereferenced shared facts, filtered by `language == $locale`, and the Content Lake returns it in one round trip rather than the waterfall of GraphQL calls a reference-heavy localised page usually demands.
The modelling rule of thumb: if changing a value in market A should change it in market B, it's shared, reference it. If not, it's localised, and it belongs to that locale's document or field.
Fallback chains: deciding what a missing translation actually does
Every localisation strategy has to answer one unglamorous question: what renders when the translation doesn't exist yet? The German page is live but the FAQ block was never translated, do you show English, show nothing, or block the whole page? Teams that don't decide this explicitly discover their answer in production, usually as a half-English page a customer screenshots.
There are three honest options, and they're per-field, not per-site. Hard fallback shows the source language so the page is never broken but may be mixed-language. Empty fallback hides the untranslated block, keeping the page monolingual at the cost of completeness. Blocking fallback refuses to publish until coverage is met, appropriate for regulated or legal content where a mixed-language page is a compliance risk. Mature sites use all three, chosen per field: marketing copy hard-falls-back, legal copy blocks.
The architecture implication is that fallback is a query and workflow concern, not a content concern, the data should record what exists, and the resolution logic decides what to show. In a GROQ-driven setup you express the fallback in the projection itself with `coalesce(title[$locale], title[$defaultLocale])`, so the rule lives in code you can test rather than in editors' heads. Pair that with a Studio that surfaces missing translations as visible state, a custom input or Structure Builder view that lists documents below coverage threshold for a locale, and 'what's missing' becomes a dashboard instead of a customer complaint. The combination matters: the query decides runtime behaviour, the editor surface decides whether humans can see the gap before it ships.
The editorial reality: giving each market a workflow without forking the model
Architecture diagrams assume one rational actor. Real localisation has a German editor on a Tuesday deadline, a French agency working in batches, and a Japanese market three timezones ahead that wants to schedule a campaign launch independently. If your CMS forces every locale through one shared publish state, you've built a coordination bottleneck that editors will route around, usually by editing in spreadsheets and pasting back, which destroys your structured content.
The requirement is per-locale workflow on a shared model: each market should be able to draft, review, schedule, and publish on its own cadence, while the content model stays singular so a schema change doesn't have to be re-implemented per language. Document-level localisation gives you the independent publish state; the challenge is keeping coordination visible so the markets don't silently diverge.
This is where the editor stops being a detail. Sanity Studio is a React application you ship, so the localisation workflow can be the workflow your markets actually run: Structure Builder can present a per-locale desk so the German editor sees only German documents in their review queue, custom input components can show side-by-side source-and-target for translators, and Content Releases with scheduling let one market stage a launch without touching another's live content. App SDK and Functions close the loop on the mechanical work, a Function can fan a newly created source document out to draft stubs in every target language, or call a translation service on publish, so editors start from a populated draft instead of a blank one. The model stays one `defineType`; the workflow flexes per market.
Operating many locales: previewing, querying, and not drowning in datasets
A two-locale site is a feature. A twenty-locale site is an operations problem. The questions change: how does an editor preview the French page in context before it's live? How do you query 'every document missing a Spanish translation' across thousands of documents without writing a batch job? How do you avoid the dataset-per-locale sprawl where you've fragmented your content into silos you can no longer query across?
The sprawl question is decided by your earlier modelling. Locale-as-a-field in one dataset keeps everything queryable together, you can ask one question across all markets, at the cost of a busier dataset. Dataset-per-locale isolates markets cleanly but means cross-market questions become cross-dataset gymnastics. For most localised sites, one dataset with a `language` field is the answer precisely because the interesting operational questions are cross-locale: coverage, drift, consistency.
Preview is the other operational gap. A headless localised page is assembled at render time from locale fields, dereferenced shared facts, and fallback logic, which means editors can't trust a raw document view to tell them what the page looks like. Visual Editing and the Presentation tool stitch the live, locale-resolved front end back to the Studio, so an editor previews the actual French page, fallbacks applied, references resolved, without the build being any less headless. And because GROQ queries the Content Lake directly, 'list every published document where `language == "es"` is missing' is a query you run, not a pipeline you maintain. The Live Content API keeps those previews current as collaborators edit, which is the difference between a coverage dashboard that's accurate and one that's a day stale.
Automating the mechanical work without losing structure
The least defensible thing a localisation team does is treat translation as copy-paste. Strings get pulled out of structured content into a flat export, translated, and pasted back, and every round trip degrades the structure: the rich-text annotations get flattened, the internal links break, the component boundaries dissolve into a wall of text. The structure you carefully modelled becomes the first casualty of the translation workflow.
The fix is to keep content structured through the entire translation loop, which means your rich text needs to be a real data format rather than an HTML blob. Portable Text represents rich content as structured data, blocks, marks, annotations, and inline references as addressable nodes, so a translation pipeline can translate the text spans while preserving the link annotations, the embedded components, and the formatting as data. The translated French version comes back with its internal links still pointing at French targets and its component embeds intact, because they were never serialised into ambiguous markup in the first place.
Automation then becomes safe to apply. Functions can trigger on a source document's publish to draft translated stubs or call a machine-translation service for a first pass that a human refines, and the App SDK lets you build that review surface directly into the Studio. Because Portable Text is structured and AI-readable, a machine-translation or enrichment step operates on the text nodes and hands back the same shape, no flatten-and-rehydrate step to lose the annotations. The principle holds regardless of vendor: localisation automation is only worth doing if the content survives the round trip with its structure intact, and that's a property of your content format, not your translation provider.