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.
