Category: Tutorial Dev

Next.js + Sanity CMS: multilingual content management from scratch

How to structure a Next.js project with Sanity to serve content in multiple languages: schema design, language-filtered GROQ queries, per-locale routes, and a language switcher.

Veronica Cussi - Aug 24, 2026 - 3 min

Next.js + Sanity CMS: multilingual content management from scratch

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.

Related articles

How to migrate a Lovable project to Supabase step by step

A practical guide to moving a Lovable project off its managed backend and into your own Supabase project: schema, auth, storage, RLS, and deployment, without losing data along the way.