How I wire Sanity CMS multilingual content to Next.js with next-intl

Jun 09, 2026 · 6 min read

Sanity CMS multilingual support with next-intl in the Next.js App Router is one of those setups where each piece works fine in isolation but the wiring between them is fiddly. This post documents exactly how I connect the two on production projects: schema design, GROQ query patterns per locale, and the slug uniqueness trap that bites every team at least once.

What each tool is actually responsible for

Before touching code, get the responsibilities straight. Sanity's document internationalisation plugin handles content storage — it creates one document per locale with a shared _id prefix (e.g. page.en, page.fr). next-intl handles routing and message delivery — locale segments in the URL, useTranslations, and the [locale] dynamic segment. Neither tool knows about the other by default. Your job is the bridge layer.

Schema design for translated fields

Install the plugin first:

npm i @sanity/document-internationalization

Then configure it in sanity.config.ts:

// sanity.config.ts
import { defineConfig } from 'sanity'
import { documentInternationalization } from '@sanity/document-internationalization'
 
export default defineConfig({
  // ...project, dataset, plugins you already have
  plugins: [
    documentInternationalization({
      supportedLanguages: [
        { id: 'en', title: 'English' },
        { id: 'fr', title: 'French' },
        { id: 'de', title: 'German' },
      ],
      schemaTypes: ['page', 'post'],
    }),
  ],
})

Now design your schema. Keep language-neutral fields (like category references or publish date) on a shared document type, and put all translatable fields on the locale document.

// schemas/post.ts
import { defineType, defineField } from 'sanity'
 
export const post = defineType({
  name: 'post',
  title: 'Post',
  type: 'document',
  fields: [
    defineField({
      name: 'language',
      type: 'string',
      readOnly: true,
      hidden: true,
    }),
    defineField({
      name: 'slug',
      type: 'slug',
      // Critical: scope uniqueness to language — see pitfalls section
      options: {
        source: 'title',
        isUnique: async (slug, context) => {
          const { document, getClient } = context
          const client = getClient({ apiVersion: '2024-01-01' })
          const id = document._id.replace(/^drafts\./, '')
          const params = { draft: `drafts.${id}`, published: id, language: document.language, slug }
          const query = `!defined(*[!(_id in [$draft, $published]) && language == $language && slug.current == $slug][0]._id)`
          return await client.fetch(query, params)
        },
      },
    }),
    defineField({ name: 'title', type: 'string' }),
    defineField({ name: 'body', type: 'array', of: [{ type: 'block' }] }),
  ],
})

The plugin injects the language field automatically after you register it in schemaTypes, but I define it explicitly anyway so TypeGen picks it up without surprises.

GROQ query patterns for locale

With document internationalisation, each locale document has the language embedded. Query pattern is straightforward — always filter by language:

// Fetch a single post by slug and locale
*[
  _type == "post"
  && language == $locale
  && slug.current == $slug
  && !(_id in path("drafts.**"))
][0] {
  _id,
  title,
  slug,
  language,
  body[]
}

For listing pages where you need alternate-locale URLs (canonical alternates for SEO hreflang), fetch all language variants via the _translations metadata reference the plugin adds:

// Get all locale versions of a document to build hreflang
*[
  _type == "translation.metadata"
  && references($id)
][0] {
  translations[]-> {
    language,
    slug
  }
}

That second query is what I use to build the alternates.languages object in Next.js metadata.

Wiring to next-intl in the App Router

Assume you already have next-intl set up with the [locale] segment under app/[locale]/. The integration point is the route handler that fetches Sanity content — pass the locale segment directly into GROQ.

// app/[locale]/blog/[slug]/page.tsx
import { getTranslations } from 'next-intl/server'
import { client } from '@/sanity/client'
import type { Metadata } from 'next'
 
interface Props {
  params: Promise<{ locale: string; slug: string }>
}
 
const postQuery = `
  *[
    _type == "post"
    && language == $locale
    && slug.current == $slug
    && !(_id in path("drafts.**"))
  ][0] {
    _id,
    title,
    slug,
    language,
    body[]
  }
`
 
const translationsQuery = `
  *[
    _type == "translation.metadata"
    && references($id)
  ][0] {
    translations[]-> {
      language,
      "slug": slug.current
    }
  }
`
 
export async function generateMetadata({ params }: Props): Promise<Metadata> {
  const { locale, slug } = await params
  const post = await client.fetch(postQuery, { locale, slug })
  if (!post) return {}
 
  const meta = await client.fetch(translationsQuery, { id: post._id })
  const languages: Record<string, string> = {}
  for (const t of meta?.translations ?? []) {
    languages[t.language] = `/${t.language}/blog/${t.slug}`
  }
 
  return {
    title: post.title,
    alternates: { languages },
  }
}
 
export default async function PostPage({ params }: Props) {
  const { locale, slug } = await params
  const t = await getTranslations({ locale, namespace: 'Blog' })
  const post = await client.fetch(postQuery, { locale, slug })
  if (!post) return <p>{t('notFound')}</p>
 
  return (
    <article>
      <h1>{post.title}</h1>
      {/* render body with Portable Text */}
    </article>
  )
}

The key line is language == $locale in GROQ. $locale comes from the URL segment — next-intl validates it against your locales config in i18n/routing.ts before the route even renders, so you will never receive an invalid value here.

The slug uniqueness pitfall

This is the one that causes silent bugs. By default Sanity's slug uniqueness check queries across all documents of that type, ignoring language. So /en/blog/welcome and /fr/blog/welcome are both valid — same slug, different locale documents. But if an editor accidentally creates a French post with an English slug and then an English post with the same value, the default validator blocks the second one.

The custom isUnique function in the schema above scopes the uniqueness check to language. That means slugs must be unique within a locale, not globally. That is usually correct — you want /en/blog/bienvenue and /fr/blog/bienvenue to be independent.

If your design requires the same slug across locales (so the URL path is identical for every language and only the locale prefix changes), add an explicit validation rule to enforce matching slugs across translations. I rarely do this because it breaks editorial flexibility, but it is a valid pattern for highly structured content.

One more trap: generateStaticParams must loop over all locales and call Sanity for each, otherwise builds silently skip locale variants:

// app/[locale]/blog/[slug]/page.tsx (continued)
export async function generateStaticParams() {
  const locales = ['en', 'fr', 'de']
  const allParams: { locale: string; slug: string }[] = []
 
  for (const locale of locales) {
    const slugs = await client.fetch<{ slug: string }[]>(
      `*[_type == "post" && language == $locale && defined(slug.current)]{ "slug": slug.current }`,
      { locale }
    )
    for (const { slug } of slugs) {
      allParams.push({ locale, slug })
    }
  }
 
  return allParams
}

Skipping this results in all locale pages being server-rendered on first request with no static fallback, which silently degrades performance without throwing any errors.

Fallback content strategy

When a translation does not exist yet, decide early: 404, redirect to the default locale, or show the default locale content with a banner. I default to a 404 via notFound() because it prevents thin-content indexation. The GROQ query returns null if no document exists for that locale + slug combination, and a null check at the top of the page component calls notFound(). Simple, safe, and does not require any extra query logic.

Related posts

All posts →