Building a multilingual website with a headless CMS sounds simple until you actually do it. Translated slugs, locale-aware routing, SEO metadata with hreflang tags, and CMS content that editors can manage per language — there's a lot of moving parts. This guide walks through a production-tested setup using Sanity CMS, Next.js (App Router), and next-intl.
Architecture Overview
The system uses a dual translation approach:
- Sanity content (articles, pages, products) — managed via Sanity's document internationalization plugin. Each language gets its own document.
- Static UI copy (buttons, labels, navigation) — managed via next-intl with JSON message files.
This separation matters. CMS content changes frequently and is managed by editors. UI copy is managed by developers and deployed with the code. Mixing them into one system creates friction for both groups.
Step 1: Sanity Document Internationalization
Install the document internationalization plugin:
npm install @sanity/document-internationalizationConfigure it in your Sanity config. The key decision is which document types need translation — typically content pages, articles, and product descriptions. Navigation and settings schemas usually don't.
// sanity.config.ts
import { documentInternationalization } from '@sanity/document-internationalization';
export default defineConfig({
// ...
plugins: [
documentInternationalization({
supportedLanguages: [
{ id: 'en', title: 'English' },
{ id: 'de', title: 'German' },
],
schemaTypes: [
'article',
'partnerPage',
'customerStory',
],
}),
],
});The plugin creates a translation.metadata document type that links translated versions of the same content together. This is how you query “give me the German version of this English article” later.
Adding a Language Field to Schemas
Every internationalized document needs a language field. The plugin populates this automatically, so make it read-only and hidden:
// sanity/schemas/objects/language.ts
import { defineField } from 'sanity';
export const language = defineField({
name: 'language',
type: 'string',
readOnly: true,
hidden: true,
});Per-Language Slug Validation
A critical detail: slugs should be unique per language, not globally. You want /blog/my-article in English and /blog/mein-artikel in German, but the slug “my-article” only needs to be unique among English documents.
// sanity/schemas/objects/slug.ts
import { defineField, SlugValidationContext } from 'sanity';
import { client } from '@/src/utils/sanity.client';
export const slug = () =>
defineField({
name: 'slug',
title: 'Slug',
type: 'slug',
options: {
source: 'title',
isUnique: isUniquePerLanguage,
},
validation: (rule) => rule.required(),
});
async function isUniquePerLanguage(
slug: string,
context: SlugValidationContext,
) {
const { document } = context;
if (!document?.language) return true;
const id = document._id.replace(/^drafts\./, '');
const query = `!defined(*[
!(_id in [$draft, $published]) &&
slug.current == $slug &&
language == $language &&
_type == $type
][0]._id)`;
return await client.fetch(query, {
draft: `drafts.${id}`,
published: id,
language: document.language,
slug,
type: document._type,
});
}Step 2: next-intl for Static UI Copy
Install next-intl and configure it with the Next.js App Router:
npm install next-intl// src/i18n.ts
import { notFound } from 'next/navigation';
import { getRequestConfig } from 'next-intl/server';
const locales = ['en', 'de'] as const;
export default getRequestConfig(async ({ locale }) => {
if (!locales.includes(locale as any)) notFound();
return {
messages: (
await (locale === 'en'
? import('../messages/en.json')
: import(`../messages/${locale}.json`))
).default,
};
});Create your translation files in messages/en.json and messages/de.json:
// messages/en.json
{
"Navigation": {
"home": "Home",
"blog": "Blog",
"about": "About"
},
"Footer": {
"copyright": "All rights reserved."
}
}Step 3: Locale-Based Routing
The App Router uses a dynamic [locale] segment. Define your route configuration with translated pathnames — this is where paths like /customers become /kunden in German:
// src/config.ts
export const locales = ['en', 'de'] as const;
export const defaultLocale = 'en';
export type Locale = (typeof locales)[number];
export const routes = [
{
slug: '/about',
translations: [
{ language: 'en', slug: '/about' },
{ language: 'de', slug: '/ueber-uns' },
],
},
{
slug: '/customers',
translations: [
{ language: 'en', slug: '/customers' },
{ language: 'de', slug: '/kunden' },
],
},
];
// Generate the pathnames map for next-intl
export const pathnames = routes.reduce((acc, route) => {
acc[route.slug] = route.translations.reduce(
(tAcc, t) => {
tAcc[t.language] = t.slug;
return tAcc;
},
{} as Record<string, string>,
);
return acc;
}, {} as Record<string, Record<string, string>>);
export const localePrefix = 'as-needed';The as-needed prefix strategy means the default locale (English) gets clean URLs without a prefix (/about), while other locales get prefixed (/de/ueber-uns).
Step 4: Middleware for Locale Detection
The middleware handles locale detection, cookie-based persistence, and — critically — translated slug resolution for Sanity content:
// src/middleware.ts
import createMiddleware from 'next-intl/middleware';
import { NextRequest, NextResponse } from 'next/server';
import { defaultLocale, locales, pathnames, localePrefix } from './config';
import { getTranslatedSlug, isLocalizedSanityUrl } from './localization';
export default async function middleware(req: NextRequest) {
const { pathname } = req.nextUrl;
const intlMiddleware = createMiddleware({
defaultLocale,
locales,
pathnames,
localePrefix,
});
const urlLocale = getUrlLocale(pathname);
const userLocale = req.cookies.get('NEXT_LOCALE')?.value;
// If user's preferred locale differs, try to redirect to translated version
if (userLocale && userLocale !== urlLocale && isLocalizedSanityUrl(pathname)) {
const translated = await getTranslatedSlug(pathname, urlLocale, userLocale);
if (translated) {
const url = req.nextUrl.clone();
url.pathname = translated;
return NextResponse.redirect(url);
}
}
return intlMiddleware(req);
}
function getUrlLocale(pathname: string): string {
const segment = pathname.split('/')[1];
return (locales as readonly string[]).includes(segment)
? segment
: defaultLocale;
}
export const config = {
matcher: ['/', '/(de|en)/:path*', '/((?!_next|api|.*\\..*).*)'],
};Step 5: Querying Translated Content from Sanity
Every GROQ query that fetches content needs a language == $locale filter. Additionally, fetch the translation metadata so you can render language switchers:
// GROQ query for a blog article with translations
const ARTICLE_QUERY = `*[
_type == "article" &&
language == $locale &&
slug.current == $slug
][0]{
_id,
title,
slug,
content,
"translations": coalesce(
*[_type == "translation.metadata" && references(^._id)][0]
.translations[].value->{
"slug": slug.current,
language,
},
[{ "slug": $slug, "language": $locale }]
),
}`;
// Usage in a page component
export default async function ArticlePage({
params,
}: {
params: { slug: string; locale: string };
}) {
const article = await client.fetch(ARTICLE_QUERY, {
slug: params.slug,
locale: params.locale,
});
// article.translations contains all available language versions
// Use this to render a language switcher
return (
<article>
<LanguageSwitcher translations={article.translations} />
<h1>{article.title}</h1>
<PortableText value={article.content} />
</article>
);
}Translating Slugs Between Languages
The slug translation utility maps between document types and their URL paths per locale, then queries Sanity for the translated version:
// src/localization.ts
import { client } from './utils/sanity.client';
// URL path to Sanity document type mapping per locale
const typeMap: Record<string, Record<string, string>> = {
en: {
'/blog': 'article',
'/customers': 'customerStory',
'/partners': 'partnerPage',
},
de: {
'/blog': 'article',
'/kunden': 'customerStory',
'/partner': 'partnerPage',
},
};
export async function getTranslatedSlug(
pathname: string,
currentLocale: string,
targetLocale: string,
): Promise<string | null> {
const basePath = '/' + pathname.split('/')[1];
const slug = pathname.split('/').pop();
const type = typeMap[currentLocale]?.[basePath];
if (!type || !slug) return null;
const result = await client.fetch(
`*[_type == $type && language == $locale && slug.current == $slug]{
_id,
"_translations": *[_type == "translation.metadata" && references(^._id)]
.translations[].value->{
slug,
language
},
}`,
{ slug, type, locale: currentLocale },
);
const doc = result[0];
if (!doc) return null;
const translation = doc._translations.find(
(t: any) => t?.language === targetLocale,
);
if (!translation) return null;
// Find the target locale's base path
const targetBasePath = Object.entries(typeMap[targetLocale] || {})
.find(([, t]) => t === type)?.[0];
return `${targetBasePath}/${translation.slug.current}`;
}Step 6: SEO Metadata with hreflang
Proper SEO requires canonical URLs and alternate language links on every page. Build a metadata helper that handles both static routes and Sanity content:
// src/utils/metadata.ts
import { locales, routes } from '@/src/config';
export function generateI18nMetadata(
title: string,
description: string,
locale: string,
path: string,
translations?: { slug: string; language: string }[],
) {
const url = buildUrl(path, locale);
// Build hreflang alternates
const languages = translations
? Object.fromEntries(
translations
.filter(Boolean)
.map((t) => [t.language, buildUrl(t.slug, t.language)]),
)
: Object.fromEntries(
locales.map((l) => [l, buildUrl(getStaticSlug(path, l), l)]),
);
return {
title,
description,
alternates: {
canonical: url,
languages,
},
openGraph: {
title,
description,
url,
locale,
},
};
}
function buildUrl(path: string, locale: string): string {
const prefix = locale === 'en' ? '' : `/${locale}`;
return `https://example.com${prefix}${path}`;
}Step 7: Caching and Revalidation
Use Next.js cache tags to invalidate content when editors publish changes in Sanity. Tag your fetches by content type, then trigger revalidation via a webhook:
// Fetch with cache tags
const article = await client.fetch(QUERY, params, {
next: { tags: [params.slug, 'blog'] },
});
// Sanity webhook handler (API route)
export async function POST(req: Request) {
const body = await req.json();
const { _type, slug } = body;
// Revalidate the specific article and its listing
revalidateTag(slug.current);
revalidateTag(typeToTag[_type]); // e.g., 'blog', 'customers'
return Response.json({ revalidated: true });
}Summary
The complete architecture comes down to these layers:
- Sanity — per-language documents linked via translation metadata, with per-language slug validation
- next-intl — static UI copy in JSON files, locale-aware navigation helpers
- Middleware — detects locale from cookies, redirects to translated Sanity slugs, delegates to next-intl for static routes
- GROQ queries — always filter by
language == $locale, always fetch translation references for language switchers - SEO — canonical URLs and hreflang alternates generated from translation data
- Caching — ISR with cache tags per content type, revalidated via Sanity webhooks
This setup has been running in production serving content in multiple languages with sub-second page loads and seamless editor experience. The key lesson: keep CMS content and UI copy as separate translation systems, and let the middleware handle the routing complexity.