Type something to search...
Modeling Multilingual Content in Sanity

Modeling Multilingual Content in Sanity

To model multilingual content in Sanity, choose between two approaches. Document-level translation creates a separate document for each language, each with a language field, linked together by the @sanity/document-internationalization plugin. Field-level translation keeps one document and stores each translatable field as an array of language values, using sanity-plugin-internationalized-array. Document-level suits content that differs a lot between languages, like articles and landing pages. Field-level suits content that is mostly shared, like products or people, where only a few fields need translating. Many projects use both.

Sanity has no single built-in "localisation mode", which is deliberate: the right structure depends on your content and your editorial workflow. Making that choice early matters, because switching later means migrating every translated document. This article explains both approaches, sets up each plugin, shows how to query translations with GROQ, handles slugs and references, and wires everything into a Next.js App Router site.

Document-Level vs Field-Level Translation

Document-levelField-level
StorageOne document per languageOne document, translated fields hold an array per language
Plugin@sanity/document-internationalizationsanity-plugin-internationalized-array
Best forArticles, pages, marketing contentProducts, people, categories, UI labels
Shared fieldsDuplicated per languageStored once
PublishingEach language published independentlyAll languages publish together
Structure per languageCan differ freelyIdentical
Document countMultiplies with languagesUnchanged

Ask two questions:

  • Does the structure differ between languages? If the French page may have different sections from the English one, use document-level.
  • Do languages publish on different schedules? If translators finish weeks after the original, document-level lets each version go live independently.

If both answers are no, and most fields are shared, field-level is simpler. The broader principles are covered in content modeling best practices in Sanity.

Approach 1: Document-Level Translation

Install the Plugin

npm install @sanity/document-internationalization

Configure It

// sanity.config.ts
import { defineConfig } from "sanity";
import { structureTool } from "sanity/structure";
import { documentInternationalization } from "@sanity/document-internationalization";
import { schemaTypes } from "./schemaTypes";

export const languages = [
  { id: "en", title: "English" },
  { id: "fr", title: "French" },
  { id: "de", title: "German" },
];

export default defineConfig({
  name: "default",
  title: "My Studio",
  projectId: "your-project-id",
  dataset: "production",
  plugins: [
    structureTool(),
    documentInternationalization({
      supportedLanguages: languages,
      schemaTypes: ["post", "page"],
    }),
  ],
  schema: { types: schemaTypes },
});

schemaTypes lists the document types that get translation controls. supportedLanguages can also be an async function that fetches languages from your dataset, though the plugin notes that async languages can't create templates for new documents.

Add a Language Field

Each translated type needs a string field named language (or whatever you set in the plugin's languageField option). The plugin writes to it, so hide it or make it read-only:

// schemaTypes/post.ts
import { defineField, defineType } from "sanity";

export const post = defineType({
  name: "post",
  type: "document",
  fields: [
    defineField({
      name: "language",
      type: "string",
      readOnly: true,
      hidden: true,
    }),
    defineField({
      name: "title",
      type: "string",
      validation: (rule) => rule.required(),
    }),
    defineField({ name: "slug", type: "slug", options: { source: "title" } }),
    defineField({ name: "body", type: "array", of: [{ type: "block" }] }),
  ],
});

How It Works

Editors see a Translations menu in the document toolbar. Choosing a language creates a copy of the document in that language, ready to translate. The plugin links the versions with a separate translation.metadata document that holds an array of references, one per language. Documents without translations have no metadata document.

Keep the document count in mind. One hundred posts in three languages are 300 post documents plus up to 100 metadata documents. The plugin's README flags this for very high-volume datasets, and it's worth comparing with your plan's document limits on how Sanity pricing works.

Excluding Fields From Duplication

New translations start as copies. For fields that shouldn't be copied, such as a slug that must be rewritten, exclude them with a plugin option:

defineField(
  {
    name: "slug",
    type: "slug",
    options: {
      source: "title",
      documentInternationalization: { exclude: true },
    },
  },
  { strict: false },
);

The strict: false argument stops TypeScript complaining about the plugin's custom option. Using defineType and defineField explains that option and a cleaner alternative with declaration merging.

Slugs Per Language

By default, Sanity requires slugs to be unique per type, so /en/about and /fr/about would clash if both used the slug "about". Allow it with a custom isUnique function that only checks within the same language. How to create custom slugs in Sanity includes a ready-made version.

Querying Document-Level Translations

Filter by language with a parameter:

*[_type == "post" && language == $language] | order(_createdAt desc){
  title,
  "slug": slug.current,
  language
}

To fetch a document with links to its translations, find the metadata document that references it:

*[_type == "post" && slug.current == $slug && language == $language][0]{
  title,
  "slug": slug.current,
  language,
  body,
  "_translations": *[_type == "translation.metadata" && references(^._id)]
    .translations[].value->{
      title,
      "slug": slug.current,
      language
    }
}

^._id refers to the post's ID from inside the subquery, and value-> dereferences each translation. The result includes the current language too, which is handy for building a language switcher. Pass $language and $slug as parameters rather than interpolating them; using GROQ parameters safely explains why.

Approach 2: Field-Level Translation

Install the Plugins

npm install sanity-plugin-internationalized-array

Configure Its

// sanity.config.ts
import { defineConfig } from "sanity";
import { internationalizedArray } from "sanity-plugin-internationalized-array";

export default defineConfig({
  // ...
  plugins: [
    internationalizedArray({
      languages: [
        { id: "en", title: "English" },
        { id: "fr", title: "French" },
        { id: "de", title: "German" },
      ],
      defaultLanguages: ["en"],
      fieldTypes: ["string", "text"],
      buttonLocations: ["field", "document"],
    }),
  ],
});

For each entry in fieldTypes, the plugin registers a new type: internationalizedArrayString, internationalizedArrayText and so on. defaultLanguages adds an empty row for those languages on new documents.

Use the Generated Types

// schemaTypes/product.ts
import { defineField, defineType } from "sanity";

export const product = defineType({
  name: "product",
  type: "document",
  fields: [
    defineField({ name: "sku", type: "string" }),
    defineField({ name: "price", type: "number" }),
    defineField({ name: "image", type: "image", options: { hotspot: true } }),
    defineField({ name: "name", type: "internationalizedArrayString" }),
    defineField({ name: "description", type: "internationalizedArrayText" }),
  ],
});

The SKU, price and image are stored once; only the name and description are translated.

Stored Data

Each translated field becomes an array with one item per language:

{
  "name": [
    { "_key": "k1", "language": "en", "value": "Running shoe" },
    { "_key": "k2", "language": "fr", "value": "Chaussure de course" }
  ]
}

Recent versions of the plugin store the language in a dedicated language field. Older versions used _key for this, so if you're upgrading an existing project, follow the plugin's migration guide before changing your queries.

Why arrays rather than an object like name.en and name.fr? Every unique field path in a dataset counts towards an attribute limit, and an object with a key per language multiplies paths quickly as languages and fields grow. Arrays keep the number of paths stable no matter how many languages you add.

Querying Field-Level Translations

Pick the value for one language, with a fallback:

*[_type == "product"]{
  sku,
  price,
  "name": coalesce(
    name[language == $language][0].value,
    name[language == "en"][0].value
  ),
  "description": coalesce(
    description[language == $language][0].value,
    description[language == "en"][0].value
  )
}

coalesce() returns the first non-null value, so untranslated fields fall back to English instead of rendering empty.

Filtering the Editor View

With many languages, every translated field shows a row per language, which gets long. The @sanity/language-filter plugin adds a toolbar control that lets editors show only the languages they work in. It integrates with the internationalized array plugin; see that plugin's README for the setup.

Combining Both Approaches

Mixing is common and sensible:

  • Posts and pages use document-level translation, because their structure and publishing schedule differ per market.
  • Categories, authors and products use field-level translation, because they're mostly shared data referenced from many documents.

A French post can then reference a category and display the category's French name with category->name[language == $language][0].value, with no duplicate category documents.

Handling References Between Languages

With document-level translation, references point to a specific language version. If an English post references an English author page, the French translation's copy of that reference still points to the English page. You have three options:

  1. Field-level for referenced types: if authors use field-level translation, there's only one author document and the problem disappears.
  2. Update references when translating: editors replace each reference with the right language version.
  3. Resolve at query time: follow the reference, then look up its translation in the same language through the metadata document.

Option 1 is the simplest for shared entities. Option 3 looks like this:

*[_type == "post" && _id == $id][0]{
  title,
  "relatedPage": coalesce(
    *[_type == "translation.metadata" && references(^.relatedPage._ref)][0]
      .translations[language == $language][0].value->{ title, "slug": slug.current },
    relatedPage->{ title, "slug": slug.current }
  )
}

It returns the related page in the requested language if a translation exists, and the original otherwise. Each item in the metadata document's translations array carries a language field alongside its value reference, which is what makes the filter work. How to dereference documents in GROQ explains the -> operator used here.

Wiring It Into Next.js

A common App Router structure puts the language in the first URL segment:

src/app/
  [lang]/
    layout.tsx
    page.tsx
    blog/
      [slug]/
        page.tsx

The post page fetches by slug and language:

// src/app/[lang]/blog/[slug]/page.tsx
import { notFound } from "next/navigation";
import Link from "next/link";
import { defineQuery } from "next-sanity";
import { client } from "@/sanity/client";

const LANGUAGES = ["en", "fr", "de"] as const;

const POST_QUERY =
  defineQuery(`*[_type == "post" && slug.current == $slug && language == $lang][0]{
  title,
  language,
  "_translations": *[_type == "translation.metadata" && references(^._id)]
    .translations[].value->{ "slug": slug.current, language }
}`);

export async function generateStaticParams() {
  const posts = await client.fetch<{ slug: string; lang: string }[]>(
    `*[_type == "post" && defined(slug.current) && defined(language)]{ "slug": slug.current, "lang": language }`,
  );
  return posts.filter((p) =>
    LANGUAGES.includes(p.lang as (typeof LANGUAGES)[number]),
  );
}

export default async function PostPage({
  params,
}: {
  params: Promise<{ lang: string; slug: string }>;
}) {
  const { lang, slug } = await params;
  const post = await client.fetch(POST_QUERY, { slug, lang });
  if (!post) notFound();

  return (
    <article lang={post.language ?? lang}>
      <h1>{post.title}</h1>
      <nav aria-label="Translations">
        {post._translations
          ?.filter(
            (t): t is { slug: string; language: string } =>
              Boolean(t?.slug && t.language) && t?.language !== lang,
          )
          .map((t) => (
            <Link
              key={t.language}
              href={`/${t.language}/blog/${t.slug}`}
              hrefLang={t.language}
            >
              {t.language.toUpperCase()}
            </Link>
          ))}
      </nav>
    </article>
  );
}

The language switcher links to each translation's own slug, so translated URLs work even when slugs differ between languages. Add alternates.languages in generateMetadata to output hreflang tags for search engines using the same _translations data.


FAQ: Multilingual Content in Sanity

Sanity stores any structure you define, so multilingual content is a modelling choice rather than a switch. The official @sanity/document-internationalization and sanity-plugin-internationalized-array plugins provide the editing interface for the two common approaches.

Use document-level when content structure or publishing schedules differ between languages, such as articles and landing pages. Use field-level when most fields are shared and only some text needs translating, such as products or categories.

Replace the slug field's default uniqueness check with a custom isUnique function that only looks for duplicates in documents with the same language value.

Each unique field path counts towards a dataset's attribute limit. An object with a key per language multiplies paths as you add languages, while an array of language values keeps the number of paths stable.

Use coalesce() with the requested language first and the default second, for example coalesce(name[language == $language][0].value, name[language == "en"][0].value).

Yes, and it's common. Translate posts and pages at document level, and shared entities like categories, authors and products at field level.


Conclusion

Multilingual content in Sanity comes down to one decision per document type: a separate document per language, or translated fields inside one document. Document-level translation with @sanity/document-internationalization gives each language its own structure and publishing schedule. Field-level translation with sanity-plugin-internationalized-array keeps shared data in one place and translates only the text that needs it.

Choose per type, combine them where it helps, and plan for slugs, references and fallbacks from the start. With language-aware GROQ queries and a [lang] segment in your Next.js routes, the same content model can serve every market without duplicating more than it needs to.

Tags :
Share :

Related Posts

Building a Static Site With Astro and Sanity

Building a Static Site With Astro and Sanity

To build a static site with Astro and Sanity, keep Astro's default output: 'static', configure @sanity/astro with useCdn: false, and fetch

Dive Deeper
Common Sanity Mistakes and How to Fix Them

Common Sanity Mistakes and How to Fix Them

The most common Sanity mistakes are using the CDN where you need fresh data, fetching whole documents with ..., leaving slugs unvalidated, ship

Dive Deeper
How to Create a Custom Sanity Plugin

How to Create a Custom Sanity Plugin

To create a custom Sanity plugin, wrap a piece of Studio configuration in definePlugin from the sanity package and add it to the plugins ar

Dive Deeper