How I Manage 12 Languages in Sanity Without Duplicating a Single Page

Programming· 5 min read

The Problem Nobody Wants to Admit

Last week I was reviewing code for a project we received for optimization. The client wanted to add Portuguese to their website that already had English, Spanish, and French.

I open the repository. 47 pages duplicated per language. Three separate folders. Three routing systems. A maintenance nightmare.

I asked: "Why aren't you using field-level translations?"

Answer: "We didn't know Sanity could do that."

And here's the problem: most developers treat multi-language content like we're still in 2015. They duplicate everything. One page in English, another in Spanish, another in French.

Until you need to update something. And you have to do it 12 times.

The Difference Between Translating Content and Translating Structure

When building multi-language, there are two approaches:

Approach 1: Total duplication

  • You create `/es/about`, `/en/about`, `/fr/about`
  • Each page is a separate document in your CMS
  • You change a title → you change it in 12 places
  • You add a new field → you update 12 schemas

Approach 2: Field-level translations (what I use)

  • A single document with translatable fields
  • Routing is generated dynamically
  • You change a title → you update a single field in multiple languages
  • You add a field → it automatically replicates for all languages

The difference isn't just technical. It's strategic.

How Field-Level Translations Work in Sanity

Instead of duplicating documents, you define which fields need translation:

```javascript // schemas/post.js import { defineType, defineField } from 'sanity'

export default defineType({ name: 'post', type: 'document', fields: [ defineField({ name: 'title', type: 'object', fields: [ { name: 'es', type: 'string', title: 'Español' }, { name: 'en', type: 'string', title: 'English' }, { name: 'fr', type: 'string', title: 'Français' }, { name: 'de', type: 'string', title: 'Deutsch' }, { name: 'it', type: 'string', title: 'Italiano' }, { name: 'pt', type: 'string', title: 'Português' }, // ... up to 12 languages ] }), defineField({ name: 'slug', type: 'slug', options: { source: 'title.es' } }), defineField({ name: 'content', type: 'object', fields: [ { name: 'es', type: 'array', of: [{ type: 'block' }] }, { name: 'en', type: 'array', of: [{ type: 'block' }] }, // ... rest of languages ] }) ] }) ```

Now you have ONE document with multiple integrated translations.

The Power of GROQ for Localized Content

This is where it gets interesting. GROQ (Sanity's query language) lets you extract exactly the language you need:

```javascript // utils/sanity.js export async function getLocalizedPost(slug, locale = 'es') { const query = ` *[_type == "post" && slug.current == $slug][0] { "title": title.${locale}, "content": content.${locale}, publishedAt, "author": author->name, "image": mainImage.asset->url } `

return await client.fetch(query, { slug }) } ```

This query: 1. Finds the post by slug (unique, not duplicated per language) 2. Extracts only the fields for the requested language 3. Returns a clean object ready to use

No complex logic. No if/else for each language. It's an interpolated string.

Smart Fallbacks When Translations Are Missing

Reality is you won't always have all translations on day 1. Here's my fallback pattern:

```javascript export async function getLocalizedContent(slug, locale = 'es', fallback = 'en') { const query = ` *[_type == "post" && slug.current == $slug][0] { "title": coalesce(title.${locale}, title.${fallback}), "content": coalesce(content.${locale}, content.${fallback}), "locale": select( defined(title.${locale}) => "${locale}", "${fallback}" ) } `

return await client.fetch(query, { slug }) } ```

The `coalesce()` in GROQ tries the primary language, and if it doesn't exist, uses the fallback. The `select()` tells you which language you ended up using.

Result: you never return empty content. You always show something.

Dynamic Routing in Next.js

For the frontend, I use Next.js App Router with a simple pattern:

```javascript // app/[locale]/blog/[slug]/page.js import { getLocalizedPost } from '@/utils/sanity'

export default async function BlogPost({ params }) { const { locale, slug } = params const post = await getLocalizedPost(slug, locale)

return ( <article> <h1>{post.title}</h1> <div>{/* render content */}</div> </article> ) }

export async function generateStaticParams() { const posts = await getAllPosts() const locales = ['es', 'en', 'fr', 'de', 'it', 'pt']

return posts.flatMap(post => locales.map(locale => ({ locale, slug: post.slug.current })) ) } ```

A single Next.js page generates routes for all languages. You don't duplicate components. You don't duplicate logic.

Why This Matters Beyond Translation

What I learned building this:

1. Content is code

When you treat content as structured data (not as HTML pages), you can apply the same DRY principles you use in your code.

2. Headless CMSs change the game

5 years ago, implementing this required custom plugins, complex databases, and lots of code. Today it's configuration.

3. Scalability isn't just technical

When I add a new language, I don't refactor routes or duplicate content. I add fields to the Sanity schema and update my locale list. 15 minutes.

What I'd Change If Starting Today

If I were building this from scratch right now:

1. Use a centralized helper for languages Instead of hardcoding `title.es`, I'd have a configuration object with all supported languages.

2. Implement automatic browser language detection With Next.js middleware to redirect to the correct language on first visit.

3. Add a "translation status" field To mark which content is 100% translated, what's in progress, what needs review.

4. Create a translation completeness dashboard A view in Sanity Studio showing which posts have which languages complete.

The Real Benefit

After 6 months with this system in production:

  • I manage content in 12 languages
  • Adding a new post takes the same time as before (just one)
  • Adding a new language: less than 30 minutes
  • Zero synchronization bugs between languages (because there's no duplication)
  • Content editors don't need to understand the code

The conclusion isn't "use Sanity" (though I recommend it). It's: stop duplicating content.

Modern tools already solved this problem. If you're still maintaining separate folders for each language, you're working harder, not smarter.

And in 2025, when Claude can write the initial configuration in 3 minutes, there's no excuse to keep doing it wrong.

Brian Mena

Brian Mena

Software engineer building profitable digital products: SaaS, directories and AI agents. All from scratch, all in production.

LinkedIn