Sanity Multilingual Content in Next.js with next-intl

By Nayan Kyada · · 6 min read

Part of The Sanity + Next.js Guide

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 how to connect the two: 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 a separate document per locale, each with a language field, and links the variants together through a translation.metadata document. 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 does not add the language field for you — every type listed in schemaTypes must define a string field matching the plugin's languageField option (default language). Defining it explicitly also means TypeGen picks it up.

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 translation.metadata document the plugin maintains:

// Get all locale versions of a document to build hreflang
*[
  _type == "translation.metadata"
  && references($id)
][0] {
  translations[].value-> {
    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 { notFound } from 'next/navigation'
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[].value-> {
      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) notFound()
 
  return (
    <article>
      <p>{t('eyebrow')}</p>
      <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's proxy (middleware) handles locale routing, and its setup guide has you validate the segment in app/[locale]/layout.tsx with hasLocale(routing.locales, locale) and call notFound() otherwise — do that so an invalid locale never reaches your GROQ query.

The slug uniqueness pitfall

This is the one that causes silent bugs. By default Sanity's slug uniqueness check looks across all documents of that type, ignoring language. Your routes treat /en/blog/welcome and /fr/blog/welcome as two separate pages — same slug, different locale documents — but the default validator sees a duplicate and blocks the second one. Editors then work around it with odd slugs like welcome-2.

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/welcome and /fr/blog/welcome to be allowed side by side.

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 the null check in the page component above calls notFound(). Simple, safe, and does not require any extra query logic.

Frequently asked questions

How do I scope Sanity slug uniqueness to a single language instead of globally?

Replace the default `isUnique` behavior with a custom async validator that filters the uniqueness check by the document's `language` field, not just `_type`. Without this, Sanity's default validator can incorrectly block a valid same-slug-different-locale pair, or (depending on your data) let unwanted cross-locale collisions through — see the schema example below for the exact GROQ filter.

How do I generate hreflang alternate links for a Sanity + next-intl multilingual site?

Query the `translation.metadata` document that Sanity's document internationalization plugin creates — it references every locale variant of a document via a `translations[]` array (each item holds the reference in `value`). Map that into Next.js's `alternates.languages` object inside `generateMetadata`, keyed by locale code and pointing at each variant's locale-prefixed URL.