Localization

Localization allows you to deliver your content in different languages for your users.

Localizing UI vs localizing content

Best practice

Localization in Sanity is performed by storing language data as a value of a field in a document.

We recommend using these two optional plugins to simplify creating and maintaining localized documents and fields in Sanity Studio.

  • For translated documents, we recommend the @sanity/document-internationalization plugin, which will relate translations as references and handle setting a “language” field value on documents.
  • For translated fields, the internationalized-array plugin can be used with any field type and scales to as many languages as you may need to author.

Methods of localization

Sanity lets you model translated content as it makes the most sense to your workflow and content structure. There are two main approaches:

  • Field-level localization
    • A single document with content in many languages
    • Requires you to publish content in all languages simultaneously
    • Achieved by creating an array that generates a field for each language value
    • Best for documents that have a mix of language-specific and common fields
  • Document-level localization
    • A unique document version for every language
    • Lets you publish each language version independently
    • References join language versions together
    • Best for documents that have unique, language-specific fields and no common content across languages
    • Best for translating content using Portable Text

Your preferred method will depend on your use case, content model, and publishing workflow. Each document’s schema plays a role in deciding the appropriate localization strategy, so you may use both in a single project.

We offer plugins for both strategies to improve the authoring experience in Sanity Studio.

Your strategy also changes how you write validation that spans documents. If a localized field has to hold a unique value, see Enforce a unique field value in Validation: the uniqueness query differs between the two strategies, and per-locale and cross-locale uniqueness are separate rules.

Sanity Studio walkthrough

Example repository

This Course Platform Demo is a Sanity Studio and Next.js front-end showcasing internationalized schema, popular plugin configuration, and how to query for localized content.

Field-level translations

Localized arrays

Any array field where each item stores the content and the language as field values. This input is customized by the internationalized-array plugin.

You may prefer to create localized fields in an array structure for projects with many languages. Arrays use fewer unique attributes than objects using this method.

Here is a quick explanation of how language objects impact attributes.

An object for a string field with three languages creates these attributes:

title
title.en
title.fr
title.es

You create another unique attribute in your dataset for every new language you add.

An array of objects to store both a language and field value could create attributes like this:

title
title[]
title[]._key
title[].language
title[].value

Using the language field to store the language and value to store the field’s content, you can add many more languages without using more attributes.

The built-in array input opens every item in a dialog, which makes this kind of authoring slow. The internationalized-array plugin solves this.

Plugin for localized arrays

The internationalized-array plugin has a custom UI that can be used for any field type and renders each field input without a popup dialog.

It stores the language in a language field.

Querying localized arrays with GROQ

When you store title in an array with the internationalized-array plugin, a query that returns it unfiltered fetches every language:

*[_type == "presenter"][0]{
  name,
  title
}

The query returns this data:

{
  "name": "Rune Botten",
  "title": [
    {
      "_type": "internationalizedArrayStringValue",
      "_key": "IW92vi98KcGFhIzeUDasfasxu",
      "language": "en",
      "value": "Rune is a solution architect at Sanity.io"
    },
    {
      "_type": "internationalizedArrayStringValue",
      "_key": "IW92vi98KcGFhIzeUDagasxu",
      "language": "es",
      "value": "Rune trabaja como arquitecto de soluciones en Sanity.io"
    },
    {
      "_type": "internationalizedArrayStringValue",
      "_key": "Edafwevi98KcGFhIzeUDkxxu",
      "language": "no",
      "value": "Rune jobber som løsningsarkitekt hos Sanity.io"
    }
  ]
}

To avoid over-fetching, update the query to:

  • Filter the array to the item whose language matches the language you need
  • Only return the value field
*[_type == "presenter"][0]{
  name,
  "title": title[language == "en"][0].value
}

Now the returned data is filtered down to only what you need:

{
  "name": "Rune Botten",
  "title": "Rune is a solution architect at Sanity.io"
}

You can use the coalesce() GROQ function to fall back to another value if the targeted one is not yet set:

*[_type == "presenter"][0]{
  name,
  "title": coalesce(
    title[language == "en"][0].value,
    title[language == "nl"][0].value,
    "Missing translation"
  )
}

For the most flexibility, use variables so that your query remains the same but will adapt to whichever parameters you pass into it.

*[_type == "presenter"][0]{
  name,
  "title": coalesce(
    title[language == $language][0].value,
    title[language == $baseLanguage][0].value,
    "Missing translation"
  )
}

Document-level translations

You might have more complex publishing workflows that field-level translations are too simple to solve. You could be working in a base language and want to publish that content as soon as it is ready, then publish translations as they become available from other editors or external translation services. Or you may have content that exists only in a certain locale. It might make the most sense to model localized content as separate documents.

In this example, we have a lesson document type where every field is unique to that language variant, so it makes sense to store them as separate documents.

Visual representation of a content model where every field is text, and so a unique document should exist for each language

Schema for document-level translations

Add a language field to each document and set it to the language that the document's contents are written in.

// ./schemas/articleType.ts

import {defineType, defineField} from 'sanity'

export const articleType = defineType({
  title: "Article",
  name: "article",
  type: "document",
  fields: [
    defineField({
      name: "language",
      type: "string",
      options: {
        list: [
          {title: 'English', value: 'en'},
          {title: 'Spanish', value: 'es'}
        ]
      }
    }),
    defineField({
      name: "title",
      type: "string",
    }),
    defineField({
      name: "body",
      type: "array",
      of: [{type: 'block'}],
    })
  ]
})

You can then filter queries for specific locales, thus only presenting the relevant localized content in your front ends.

Using our Document Actions API you can further add actions in your studio for duplicating a document into another locale and then translate the content manually.

Or use GROQ-powered webhooks to send the document off to a third-party translation service through their API for automated or professional translation. Once the translation is complete, you can re-import it to your Sanity dataset through, for example, a webhook triggered by the translation service.

You can also use the Structure Builder API to provide segmented navigation to find and organize localized content in the Structure tool if you wish.

Filtered document lists for authors to quickly find documents of a specific language

Plugin for document-level translations

The document internationalization plugin handles setting a language field and relating translations as references

An integrated solution is to install the @sanity/document-internationalization plugin, which provides most of the above in-Studio features with minimal setup. It handles setting a language field on documents and automatically creates a linked document that stores the translations together so they are more easily queried.

Querying for localized documents with GROQ

How you query for translated documents will depend on how you have built references between them. If you use the @sanity/document-internationalization plugin, your query will look like the one below.

In this query, you are looking for a lesson type document of a specific language, then find the translation.metadata type document which contains a reference to it and other language translations.

*[_type == "lesson" && language == $language]{
  title,
  slug,
  language,
  // Get the translations metadata
  // And resolve the `value` reference field in each array item
  "_translations": *[_type == "translation.metadata" && references(^._id)].translations[].value->{
    title,
    slug,
    language
  },
}

The plugin’s page contains more details on how to query for translations in both GROQ and GraphQL.

Translating content with the AI Assist plugin

The official AI Assist plugin for Sanity Studio offers content translation powered by large language models (LLMs).

Translation service plugins

In addition to plugins to assist with authoring localized content in Sanity Studio, we offer some adapters to popular translation service providers:

Customizing the internationalized array plugin

The sanity-plugin-internationalized-array plugin supports several configuration options beyond the basic setup. This section covers the most common customization patterns.

Load languages from an external source

Instead of hardcoding a language list, you can load languages dynamically from an API or from documents in your dataset. Pass an async function to the languages option:

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

export default defineConfig({
  // ...
  plugins: [
    internationalizedArray({
      languages: async () => {
        // Fetch from an external API
        const response = await fetch('https://example.com/api/languages')
        return response.json()
      },
      // Or load from documents in your dataset:
      // languages: async (client) => {
      //   return client.fetch('*[_type == "language"]{id, title}')
      // },
      fieldTypes: ['string', 'text'],
    }),
  ],
})

Filter languages by market

Use the select option to pass fields from the document being edited into the languages callback, so each document offers only the languages for its market. In this example, each language document has a markets array of strings, and the document being edited has a single market field:

internationalizedArray({
  select: {
    market: 'market',
  },
  languages: async (client, {market = ''}) =>
    client.fetch(
      `*[_type == "language" && $market in markets]{id, title}`,
      {market},
    ),
  fieldTypes: ['string'],
})

Configure the add language button

Control where the "Add translation" button appears using the buttonLocations option. Available locations are field (below the field), fieldMenu (one translate icon per field that opens a language menu, v5.3.0 or later), and document (above the document fields). Locations are additive. Don't use unstable__fieldAction: on plugin v4.0.4 or later, it crashes the document editor in Studio v4 and above. For example:

internationalizedArray({
  languages: [
    {id: 'en', title: 'English'},
    {id: 'fr', title: 'French'},
  ],
  fieldTypes: ['string'],
  buttonLocations: ['field', 'fieldMenu'],
  buttonAddAll: false, // Hide the "Add all languages" button
})

Complex field types

The plugin supports complex field types including Portable Text, references, and custom objects. Add these types to the fieldTypes array:

import {defineField} from 'sanity'
import {internationalizedArray} from 'sanity-plugin-internationalized-array'

internationalizedArray({
  languages: [
    {id: 'en', title: 'English'},
    {id: 'fr', title: 'French'},
  ],
  fieldTypes: [
    'string',
    'text',
    // Portable Text (block content)
    defineField({
      name: 'blockContent',
      type: 'array',
      of: [{type: 'block'}],
    }),
    // References
    defineField({
      name: 'relatedArticle',
      type: 'reference',
      to: [{type: 'article'}],
    }),
  ],
})

Control automatic language reordering

By default, the plugin reorders internationalized array items to match the order of the languages option whenever you open a document whose stored order differs. The reorder is written as a patch, so on a published document it creates a draft and a history entry, even though no content changed.

To keep the stored order and stop the plugin from patching on open, set restoreOrder to false. This option requires sanity-plugin-internationalized-array v5.2.0 or later:

internationalizedArray({
  languages: [
    {id: 'en', title: 'English'},
    {id: 'fr', title: 'French'},
  ],
  fieldTypes: ['string'],
  restoreOrder: false, // Default is true
})

If you keep restoreOrder at its default, the patch happens once per document, not on every open. After you publish the reordered draft, the stored order matches the languages option, so opening the document again leaves it unchanged.

For the full list of configuration options, see the plugin README on GitHub.

Migrate the internationalized array plugin from v4 to v5

Version 5 of the internationalized array plugin introduced a breaking change: the language identifier moved from the _key field to a dedicated language field. The _key field now holds a random, stable key for array operations. This change fixes issues with copy/paste, reordering, and Portable Text fields.

What changed

In v4, the language was stored in the array item key:

{
  "_key": "en",
  "value": "Hello world"
}

In v5, the language has its own field:

{
  "_key": "a1b2c3d4",
  "language": "en",
  "value": "Hello world"
}

Update your GROQ queries

After migrating, update any GROQ queries that filter by _key to use the language field instead:

// Before (v4)
*[_type == "product"]{ "title": title[_key == "en"][0].value }

// After (v5)
*[_type == "product"]{ "title": title[language == "en"][0].value }

Update a custom language filter function

If you use @sanity/language-filter directly with a custom filterField function, update that function too. In v4, the language was part of the field path, so custom functions often read it from the end of the path with member.field.path.slice(-2). In v5, the path ends with the random _key instead, so a function written this way finds no language and hides every translation.

Read the language from parentValue instead. It's the fourth argument passed to filterField in @sanity/language-filter v4.1.0 and later, and it holds the array item being rendered, including its language field. Fall back to _key so that items you haven't migrated yet still filter correctly. The plugin's built-in filter, internationalizedArrayLanguageFilter, uses the same fallback. It also keeps items with an unrecognized language visible, so that you can correct them. Here's a custom function with the same fallback:

import {defineConfig} from 'sanity'
import {languageFilter} from '@sanity/language-filter'

export default defineConfig({
  // ...
  plugins: [
    languageFilter({
      supportedLanguages: [
        {id: 'en', title: 'English'},
        {id: 'fr', title: 'French'},
      ],
      documentTypes: ['lesson'],
      filterField: (
        enclosingType,
        field,
        selectedLanguageIds,
        parentValue,
      ) => {
        const isArrayItem =
          enclosingType.name.startsWith('internationalizedArray') &&
          enclosingType.name.endsWith('Value')

        if (!isArrayItem) {
          // Keep any existing logic for other localized fields here
          return true
        }

        // Fall back to _key for items not yet migrated to v5
        const language =
          typeof parentValue?.language === 'string'
            ? parentValue.language
            : typeof parentValue?._key === 'string'
              ? parentValue._key
              : null

        return language ? selectedLanguageIds.includes(language) : false
      },
    }),
  ],
})

If your function only filters internationalized array fields, you can remove it and use the plugin's built-in languageFilter option instead. See usage with @sanity/language-filter in the plugin README.

For detailed migration steps and a data migration script, see the migration guide in the plugin README.

Was this page helpful?

sanity.ioAll Systems Operational© Sanity 2026
Change site theme