How I build a multilingual website with Sanity and Next.js
By Nayan Kyada · · 6 min read
Part of The Sanity + Next.js Guide
A multilingual website on Sanity and Next.js is mostly a modelling decision made in week one, then a routing and SEO job. Get the content model wrong and every language you add multiplies the pain. This is the order I work in: content model, routing, hreflang and sitemaps, then the editor workflow.
If you want the next-intl wiring in more detail, I covered it in Sanity multilingual content in Next.js with next-intl. This post is the wider decision flow around it.
Document-level vs field-level i18n in Sanity
There are two models, and they suit different content.
| Document-level | Field-level | |
|---|---|---|
| Shape | One document per language, linked by a translation key | One document, each field is an object keyed by locale |
| Slugs per language | Natural | Awkward |
| Publish per language | Yes | No, one publish for all |
| Partial translation | Easy, missing doc just does not exist | Messy, empty fields everywhere |
| Studio clutter | More documents | Wide, long forms |
| Best for | Pages, posts, products | Labels, short UI strings |
My default is document-level. Real sites need translated slugs, per-language SEO titles, and the ability to publish French on Tuesday while German is still in review. Field-level breaks all three. It looks tidy for the first two languages and becomes a wall of tabs by the fourth.
Field-level still earns a place for small shared things, such as a call-to-action label inside a reusable object. I mix them: document-level for anything that owns a URL, field-level for tiny strings that never need their own lifecycle.
Sanity maintains a document internationalization plugin (@sanity/document-internationalization) that handles the translation metadata and a language switcher in the Studio. Check its README for the current config shape, since options change between versions.
The schema
Each translatable document gets a language field, which the plugin reads and writes. Keep it hidden from editors so nobody changes it by accident.
// sanity/schemaTypes/page.ts
import {defineField, defineType} from 'sanity'
export const page = defineType({
name: 'page',
title: 'Page',
type: 'document',
fields: [
defineField({name: 'language', type: 'string', readOnly: true, hidden: true}),
defineField({name: 'title', type: 'string', validation: (r) => r.required()}),
defineField({
name: 'slug',
type: 'slug',
options: {source: 'title', maxLength: 96},
validation: (r) => r.required(),
}),
defineField({name: 'seoTitle', type: 'string'}),
defineField({name: 'body', type: 'array', of: [{type: 'block'}]}),
],
})Slug uniqueness should be per language, not global. The default slug check is global, so a translated page with the same slug in another language is flagged. My notes on this are in Sanity slug validation; the fix is a custom isUnique that adds a language filter.
Next.js i18n routing in the App Router
The App Router has no built-in i18n config like the old Pages Router did. You build it with a [locale] segment and a proxy (the file formerly called middleware, renamed proxy.ts in Next.js 16) for detection and redirects. Most teams add next-intl for this; it handles locale negotiation and typed navigation.
app/
[locale]/
layout.tsx
page.tsx
[slug]/page.tsxI use locale prefixes on every language, including the default (/en/about, /de/about). Hiding the prefix for the default looks cleaner, but it creates edge cases in canonicals and redirects. Pick one rule and keep it everywhere.
The page query filters by both slug and language:
*[_type == "page" && slug.current == $slug && language == $locale][0]{
title,
seoTitle,
body,
"alternates": *[_type == "translation.metadata" && references(^._id)][0].translations[]{
"language": _key,
"slug": value->slug.current
}
}The translation.metadata document is what the document internationalization plugin creates to link translations. It is the source for your hreflang data, so query it once per page rather than guessing URLs.
If you also have per-language menus, read multi-locale navigation without bloating queries before you design them.
Hreflang and sitemaps
Search engines need to know that /en/about and /de/ueber-uns are the same page in different languages. Three rules matter: every page lists all alternates including itself, links are reciprocal, and the canonical points to the page's own language version, not to English.
// app/[locale]/[slug]/page.tsx
import type {Metadata} from 'next'
import {getPage} from '@/sanity/lib/getPage'
const BASE = 'https://example.com'
export async function generateMetadata(
{params}: {params: Promise<{locale: string; slug: string}>}
): Promise<Metadata> {
const {locale, slug} = await params
const page = await getPage(slug, locale)
if (!page) return {}
const languages: Record<string, string> = {}
for (const t of page.alternates ?? []) {
languages[t.language] = `${BASE}/${t.language}/${t.slug}`
}
return {
title: page.seoTitle ?? page.title,
alternates: {
canonical: `${BASE}/${locale}/${slug}`,
languages,
},
}
}Note that params is a promise in current Next.js, so it must be awaited. Add an x-default entry if you have a language selector or fallback page. Only list languages that actually exist for that page; pointing hreflang at a 404 is a common and silent mistake.
For the sitemap, generate one entry per document, and put the same alternates on each. Next.js sitemap.ts supports an alternates.languages field on each entry, so you can reuse the same data. Fetch all pages with their translation metadata in one query, not one query per locale.
Editor workflow for translations
The tech is the easy part. The workflow decides whether the second language ever gets maintained. What I set up:
- A language switcher on each document in the Studio, so editors create a translation from the source page rather than starting blank.
- A structure builder grouping by language, so a German editor only sees German documents. See Structure Builder ordering and grouping.
- Role-based access so translators cannot touch the source language. See document-level permissions.
- A status field or review step so half-translated pages are not published by accident.
Decide before launch what happens when a translation is missing. The two sane options are a 404 for that locale or a visible fallback to the source language with a noindex tag. Silent fallback with a self-canonical creates duplicate content.
What drives the cost of adding languages
If you are budgeting, these are the real drivers, in rough order of size:
- Translation itself: professional translators, per word, and ongoing for every content update. This usually dwarfs development.
- Design QA per locale: longer German strings, right-to-left layouts, different date and currency formats.
- Content model complexity: translated slugs, per-language SEO fields, and localized references add schema and query work.
- Migration of existing content, if a legacy site already has translations to map.
- Ongoing editor time: every new page now needs N versions.
The first language setup is a one-off engineering cost; each additional language is mostly content and QA. A fixed-scope Sanity + Next.js build starts from $4,000, and I scope multilingual as part of that up front rather than as a retrofit, because retrofitting i18n into a single-language schema means a content migration.
How to verify it works
- View source on each page and confirm the alternates list every language, with matching reciprocal links.
- Check the sitemap contains each language URL once.
- Open a page with a missing translation and confirm it behaves the way you decided.
- Run the Search Console international targeting and page indexing reports after launch for hreflang errors.
Frequently asked questions
01Should I use document-level or field-level i18n in Sanity?
Use document-level for marketing sites and blogs where each language can have its own slug, SEO fields and publish date. Use field-level only for small, tightly coupled content like labels or a few short strings where every language must ship together.
02Do I need hreflang for a multilingual Next.js site?
Yes, if the same content exists in several languages or regions. Each page should list all its language alternates, including itself, so search engines serve the right version. In Next.js you can set these through the metadata API's alternates.languages field.
03What makes adding a language to a Sanity site expensive?
Translation itself is usually the biggest line, followed by per-locale design QA, since German or Arabic text breaks layouts that fit English. Schema and routing work is mostly one-off, so the second language costs more engineering than the fifth.