Happening this week! Hear how Amplitude built a self-serve marketing engine to drive growth 🚀

Localization

Localize your content

Localizing UI vs localizing content

This article is about how to localize the content you manage in Sanity Studio. To learn about how to change the UI language of your studio, visit this article, or visit this article if you want to learn about adding internationalization to your plugins.

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 allows you to 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 or object that generates a field for each language value
    • Best for documents that have a mix of language-specific and common fields
    • Not recommended for Portable Text
  • Document level localization
    • A unique document version for every language
    • Allows the option to 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 simple plugins for both strategies to improve the authoring experience in Sanity Studio.

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

An object field schema where each language is represented by its own unique field input for the value

Instead of using a single field for a single value, field-level translations are created by building arrays or objects with a field for each language representing that value.

This is most useful in document schemas where only some fields in the document require translation. For example, a person will use the same name and photo in every language, but their title would need localizing.

So we create a structure like this with a title field for each language:

Visual representation of a "presenter" schema where some fields are global and some are localized

Schema for localized objects

Since Sanity schema definitions are written in JavaScript, you can programmatically define the language properties for these localized versions of fields. Adding languages becomes a task of expanding the list of languages, and all fields will be expanded to include them.

In the following code example, we are also using the fieldset feature of objects to group every language value except a "base language" into a collapsible group in order to tidy up the Studio document editor.

Gotcha

Creating localized objects is fine for a few languages and fields, but it risks rapidly increasing your attribute count with many languages. Consider using arrays instead with the help of a plugin, detailed below.

// ./schemas/localeStringType.ts

import {defineType, defineField} from 'sanity'

// Since schemas are code, we can programmatically build
// fields to hold translated values. We'll use this array
// of languages to determine which fields to define.
const supportedLanguages = [
  { id: 'en', title: 'English', isDefault: true },
  { id: 'no', title: 'Norwegian' },
  { id: 'fr', title: 'French' }
]

export const baseLanguage = supportedLanguages.find(l => l.isDefault)

export const localeString = defineType({
  title: 'Localized string',
  name: 'localeString',
  type: 'object',
  // Fieldsets can be used to group object fields.
  // Here we omit a fieldset for the "default language",
  // making it stand out as the main field.
  fieldsets: [
    {
      title: 'Translations',
      name: 'translations',
      options: { collapsible: true }
    }
  ],
  // Dynamically define one field per language
  fields: supportedLanguages.map(lang => ({
    title: lang.title,
    name: lang.id,
    type: 'string',
    fieldset: lang.isDefault ? null : 'translations'
  }))
})

Protip

Generalizing objects into their own “named types” makes them easily reusable across different documents. Many fields with unique name values could use this same localeString type.

// ./schemas/presenterType.ts

import {defineType, defineField} from 'sanity'
import {baseLanguage} from './localeStringType.ts'

export const presenterType = defineType({
  title: 'Presenter',
  name: 'presenter',
  type: 'document',
  fields: [
	defineField({
      name: 'name',
      type: 'string'
    }),
	defineField({
      name: 'title',
      type: 'localeString'
    }),
  ],
  preview: {
    select: {
	  title: 'name',
      subtitle: `title.${baseLanguage.id}`
    }
  }
})

Protip

The IANA language tags can be helpful when selecting identifiers for your supported languages.

Plugin for localized objects

To clean up the UI of a document with localized fields, you may choose to install the language filter-plugin to allow editors to hide languages they won’t need to interact with.

Querying localized objects with GROQ

Here is an example of content structured according to this schema, as it appears fetched from our query API:

*[_type == "presenter"][0]{
  name,
  title
}
{
  "name": "Rune Botten",
  "title": {
    "en": "Rune is a solution architect at Sanity.io",
    "es": "Rune trabaja como arquitecto de soluciones en Sanity.io",
    "no": "Rune jobber som løsningsarkitekt hos Sanity.io"
  }
}

When using this content in a front-end, you can fetch the full document as above or return a specific title field value depending on which locale you are interested in displaying.

This example fetches only the English title and returns that value in the title property:

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

This will return the following JSON from our previous content example:

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

If you query the title in a language that might not have a value set yet, you can use the coalesce function in GROQ to provide a fallback. In this case, the title.sv property won't have a value (since no sv language value is set in our example), so the English value is used instead, returning the same JSON as the example above. It would have fallen further back to the string Missing translation if it had not had an English value set either.

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

Lastly, you may prefer to use variables in your GROQ query so that the query does not need to change when your desired language changes – just the values of the parameters supplied to the query:

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

Localized arrays

Any array field where each item stores the content and the language as field values – this input is customised 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 component is not best suited to authoring like this – as every array item needs to open in a popup dialog – but there is a solution.

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 also saves an extra attribute by writing the language value to the _key field – something you cannot customize in the Studio with a regular schema.

Querying localized arrays with GROQ

Now performing the same query for name and title but with the title stored in an array, using the internationalized-array plugin.

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

You will receive this data:

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

To avoid over-fetching, update the query to:

  1. Filter this array to just the language field _key you need
  2. Only return the value field
*[_type == "presenter"][0]{
  name,
  "title": title[_key == "en"][0].value
}

Now the returned data is filtered down to just 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[_key == "en"][0].value,
    title[_key == "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[_key == $language][0].value,
    title[_key == $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

The simplest way to achieve this is to have a language field on documents and set this to whichever language the document's contents correspond to.

// ./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 the 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 via, 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 Desk 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 Large Language Model-powered content translation at the click of a sparkly button.

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:

Was this article helpful?