When a site needs to serve content in multiple languages, the temptation is to duplicate the whole CMS per language, or cram every language into one document with a tab per field. Both options break down as the site grows. This is the structure I use in VeroLab with Next.js and Sanity, and it's the same one behind this blog.
The most common mistake: multilingual fields on a single document
A common pattern is a single document with title_es, title_en, title_fr, body_es, body_en, body_fr. It works at first, but becomes unmanageable the moment you want to publish one language before another, give each version independent SEO, or let an editor work only in French without touching the rest. The pattern that scales better is one document per language, linked together.
The schema: one document per language + translationGroup
Every post has a language field (es | en | fr) and a translationGroup field, an identifier shared across the three versions of the same article. In the Sanity schema it looks like this:
defineField({ name: 'language', type: 'string', options: { list: ['es', 'en', 'fr'] } }), defineField({ name: 'translationGroup', type: 'string' })
With this, each language is an independent document — with its own slug, its own publish state, and its own editorial flow — and translationGroup is the glue that tells the frontend which documents are "the same page" in another language.
Language-filtered GROQ queries
To list posts in a given language, the filter is direct: *[_type == "post" && language == $lang] | order(publishedAt desc). For the language switcher on a detail page, you look up the other documents sharing the same translationGroup: *[_type == "post" && translationGroup == $group && language != $lang]{ language, slug }.
Per-locale routes in Next.js
With the App Router, the typical structure is app/[lang]/blog/[slug]/page.tsx. In generateStaticParams you generate every language + slug combination by querying Sanity, and on the page you resolve the document by filtering on both language and slug.current. This avoids collisions when two languages happen to share the same slug.
SEO: hreflang without headaches
Since each language is a document with its own slug, generating hreflang tags is just a translationGroup query at metadata-generation time (generateMetadata), linking each version to its actual URL instead of assuming a fixed pattern like /es/, /en/, /fr/ in front of the same slug.
The frontend language switcher
If an article doesn't have a published translation in a given language yet, the switcher simply doesn't show that option instead of leading to a 404 or the homepage. It's one extra query (the translationGroup lookup), but it avoids a pretty common bad experience on poorly built multilingual sites.
Why this structure scales
Each language can be published, edited, or even deleted independently without touching the others. You can have an editor working only in French. And if you add a fourth language tomorrow, you don't touch the schema of existing documents, you just add one more option to the language list.
If you're setting up a multilingual Next.js + Sanity project and want a second opinion on the architecture before writing code, reach out.
