How to Manage Multilingual SEO With a Headless CMS
You ship a Spanish version of your top landing page, and three weeks later Search Console shows the English original still outranking it in Mexico, while the Spanish URL is flagged as a duplicate. The translations were fine.
You ship a Spanish version of your top landing page, and three weeks later Search Console shows the English original still outranking it in Mexico, while the Spanish URL is flagged as a duplicate. The translations were fine. The problem was structural: no reciprocal hreflang cluster, locale fallbacks that silently rendered English into half-translated pages, and a content model where adding a fourth language meant a schema migration. Multilingual SEO fails at the seams between how content is modeled, how it is queried, and how the frontend tells Google which version belongs to whom.
Sanity is the Content Operating System for the AI era, and for multilingual work that matters because localization here is a modeling decision made in code, not a checkbox bolted onto a fixed field layout. When your locales, your query shapes, and your hreflang output all derive from one schema-as-code source, the seams stop leaking.
This guide walks the full path a developer actually builds: choosing field-level versus document-level localization, querying exactly the locale shape you need in one round trip, generating reciprocal hreflang from that same query, and automating first-draft translation without letting a machine ship unreviewed copy.
The two failure modes: duplicate content and broken fallbacks
Two problems account for most multilingual SEO damage, and both trace back to modeling and delivery rather than translation quality. The first is duplicate content. Google treats localized page versions as duplicates only when the main content stays untranslated, so a page that ships with an English body under a /fr/ path competes with itself and dilutes both versions. The fix is genuine translation plus hreflang annotations that tell Google these are language variations, not clones.
The second failure mode is the silent fallback. When a translation is missing, a naive query returns null, and the frontend either crashes or renders an empty region. Teams overcorrect by falling back to the base language everywhere, which reintroduces the untranslated-content duplicate problem one field at a time. A German page peppered with English strings is exactly the half-translated main content Google penalizes, and no editor asked for it. You need fallbacks that are deliberate, visible, and ideally flagged for editors to finish.
Both failures share a root cause: the content model, the query, and the SEO signals are usually maintained by three different systems that do not know about each other. The locales live in a CMS setting, the query hardcodes a language, and hreflang is hand-authored in a template. Change one and the others drift out of sync. In Sanity, all three derive from schema-as-code in your repository. The Content Lake decouples content structure from storage, so the same locale definitions that shape your documents also shape your GROQ queries and, from those queries, your hreflang output. This is the modeling stance the rest of the guide builds on, because you cannot fix broken fallbacks or duplicate content with better copy; you fix them by making one source of truth drive every downstream signal.
Choosing your model: field-level versus document-level localization
Sanity models multilingual content two ways, and the choice shapes everything downstream. Both can coexist in one project, so this is a per-document-type decision, not a project-wide one.
Field-level localization keeps a single document with content in many languages, storing each field's translations as an array of language and value pairs. The internationalized-array plugin provides a custom Studio input that renders every language inline, with no popup dialog, and works with any field type. This suits documents with a mix of language-specific and shared fields, think a product page where the price and SKU are common but the description and title are translated. You publish all languages together, which keeps parity tight for marketing pages that launch in lockstep.
Document-level localization creates a unique document per language, related by references, with a language field set on each. The @sanity/document-internationalization plugin manages those relationships and lets each locale publish independently. This is the right call when translations diverge in content, ship on different schedules, or lean on long-form Portable Text where you want a clean per-language document rather than deeply nested arrays.
A defensible default: reach for field-level (internationalized-array) on shared-layout marketing pages where locales stay in sync, and switch to document-level (document-internationalization) when locales publish independently or drift apart editorially. The point is that Sanity Studio is a React app you configure per document type, so this is not a global toggle you regret later. You model your business first, then the localization strategy follows the actual editorial reality of each content type rather than forcing every type through one fixed locale mechanism the way UI-configured CMSes tend to.
Why the array shape scales where per-language fields do not
The unglamorous detail that decides whether your model survives its tenth locale is how languages are stored at the dataset level. It is worth getting concrete, because this is where fixed-model CMSes quietly accumulate debt.
Store three languages as an object and you create these attributes: title, title.en, title.fr, and title.es. Every new language adds another unique attribute to your dataset. At four locales it is a nuisance; at fifteen, with several localized fields per type, it is schema churn that touches migrations, TypeScript types, and every query. Each language is a structural change to your content shape.
Store the same three languages as an internationalized array and the attributes are title, title[], title[]._key, title[].language, and title[].value. Adding a language adds array items, not schema attributes. The shape is identical whether you support two locales or twenty, which means your schema, your generated types, and your queries stay stable as the business expands into new markets. Using the language field to hold the code and the value field to hold the content, you add many more languages without using more attributes.
This is the difference between a model that scales output and one that scales work. When adding Portuguese is an array entry rather than a schema migration plus a query rewrite plus a redeploy, a small team can localize into many markets without linear growth in engineering effort. TypeGen turns those portable defineType schemas into TypeScript, so the moment you change the model, your types follow and your editor tells you where a query needs updating. The array shape is not a stylistic preference; it is the mechanism that keeps a fifteen-locale site as maintainable as a two-locale one.
Querying exactly the locale you need in one round trip
Modeling is half the story. The other half is delivery, and this is where GROQ earns its place over a GraphQL round trip that returns a fixed field set. With a localized array, you filter to the single language you need and return only its value, avoiding the over-fetch of pulling every translation to the client and discarding all but one.
A basic projection looks like this: *[_type == "presenter"][0]{ name, "title": title[language == "en"][0].value }. That asks for exactly the shape the frontend renders, in one request, with the locale filtering happening in the Content Lake rather than in your application code.
Fallbacks are where the earlier broken-fallback failure gets solved deliberately. GROQ's coalesce() lets you specify an explicit fallback chain: coalesce(title[language == $language][0].value, title[language == $baseLanguage][0].value, "Missing translation"). The requested locale wins; if it is unset, you drop to a base language; if that is missing too, you render a visible placeholder rather than a silent null. Swapping "Missing translation" for a value your frontend can detect lets you flag incomplete locales in the UI instead of shipping half-translated pages that Google reads as duplicate content.
Crucially, the $language and $baseLanguage parameters keep one query reusable across every locale. You do not maintain a query per language; you pass parameters, and the same GROQ projection adapts. That single-round-trip, parameterized, fallback-aware query is what you wire into your Next.js data layer through next-sanity, backed by the official Vercel partnership, so each localized route fetches precisely its locale shape at build or request time with no client-side locale juggling.
Generating reciprocal hreflang from your locale data
hreflang is where careful modeling meets Google's actual requirements, and it is the step most teams get subtly wrong. hreflang tells Google that localized URLs are genuine language or region variations rather than duplicates. You can declare it three equivalent ways: HTML <link rel="alternate" hreflang> tags in the head, HTTP headers, or an XML sitemap. Pick one; mixing them buys nothing.
The rule that trips people up is reciprocity. Every language version must list itself and all other versions using fully-qualified URLs. If two pages do not both point to each other, Google ignores the annotations entirely, so a one-directional link from English to French with no French-to-English link back means neither gets the benefit. This is precisely the kind of drift that happens when hreflang is hand-maintained in templates and one locale gets added without updating the others.
The clean pattern is to derive hreflang from the same data that drives your content. Because your document knows its language field and, in the document-level model, references its sibling translations, one GROQ query can return the full set of locale URLs for a page. Your Next.js App Router route then maps that set into a complete, self-referential, reciprocal cluster of <link rel="alternate"> tags, plus an x-default entry pointing at your language selector or unmatched-language fallback. Add an entry for every locale the query returns, including the current one, and reciprocity is structural rather than something an editor has to remember.
The strategic layer above the tags: give each locale its own crawlable URL, and current practice favors subdirectories (/es/, /de/) over subdomains or ccTLDs, because subfolders consolidate domain authority on one host while separate domains split it and multiply operational overhead.
Automating first-draft translation without shipping unreviewed copy
Manual translation does not scale to dozens of locales, but fully automated translation ships errors into production. The workable middle is automating the first draft and keeping a human in the loop, which maps to the Automate everything pillar without letting the machine own the outcome.
Sanity's AI Assist plugin ships two translation APIs deliberately aligned to the two localization strategies. Full document translation is configured under translate.document with a languageField naming the field that holds the language code, for example en, fr, or nb_NO, and works especially well with the Document internationalization plugin. Field-level translation is configured under translate.field with documentTypes and languages, designed for the internationalized-array setup. Matching the translation API to the model you already chose means the automation slots into your existing structure rather than forcing a second content shape.
For programmatic pipelines beyond the Studio, Agent Actions expose schema-aware HTTP APIs for generating, transforming, and translating content with LLMs, callable anywhere you can run code. That is where a scheduled or event-driven translation workflow plugs in: a new base-language document lands, an action drafts the other locales, and editors review and approve before publish.
The governance caveat is not optional and Sanity's own documentation states it plainly: like all current large language model based tools, AI Assist should never be relied on for critical content without a human reviewing the results. Wire the automation to produce drafts, not published documents, and pair it with Content Releases so a batch of newly translated locales moves through review and goes live together on a schedule. Note that AI Assist is a paid feature on the Growth plan and up, so factor licensing into the workflow. The reframe: automation raises the floor on how fast you can localize; review, not the model, still decides what ships.