Say you want to translate the title field of the type named blogPost. It might look something like this:

  title: 'Blog Post',
  name: 'blogPost',
  type: 'object',
  fields: [
      title: 'Title',
      name: 'title',
      type: 'string'

Title is no longer going to just be a string, but rather a type that we define ourselves with the translations we would like to make available:

  name: 'title',
  title: 'Title',
  type: 'localeString' // used to be 'string'

Make a new file, perhaps localeString.js, and import it into your schema. As Sanity schemas are javascript we can programmatically roll out the different language inputs:

const supportedLanguages = [
  {id: 'en', title: 'English', isDefault: true},
  {id: 'es', title: 'Spanish'},
  {id: 'nb', title: 'Norwegian'}

export default {
  name: 'localeString',
  type: 'object',
  fieldsets: [
      title: 'Translations',
      name: 'translations',
      options: {collapsable: true}
  fields: => (
      title: lang.title,
      type: 'string',
      fieldset: lang.isDefault ? null : 'translations'

What's happening here? We defined a type named localeString. It gets one field for each language defined in the supportedLanguages array. All the fields, except the ones flagged as default: true, are wrapped in a collapsible fieldset. The default fields are rendered above the fieldset, implying that providing a translation for these are of higher priority.

Pro tip: When choosing id for your supported languages, you might save some woe by grabbing the IDs from the IANA language tags.

So, what does the end result look like?

Rendered in the Studio

The stored data

Coming from the data store, title will be shaped like this:

  "_type": "localeString",
  "en": "How to localize your Sanity Studio",
  "es": "Cómo localizar su Sanity Studio",
  "nb": "Hvordan lokalisere ditt Sanity Studio"

Querying localized data

To query for a localized field, you can either select the field containing all your localizations and do the selection on the client side, or you can alias the field when querying:

*[_type == "blogPost"]{..., "title": title.nb}

If you want to fall back to a default locale if the one you are selecting is not present, you can use GROQs coalesce function to accomplish this. It returns the first value that is set:

*[_type == "blogPost"]{..., "title": coalesce(title.nb, title.en)}

Since you can address an attribute based on a query-variable, you can set the current language as a query-variable and fallback to a default language like this:

// Set $language to the current selected language, e.g. "no", then:

*[_type == "blogPost"]{..., "title": coalesce(title[$language], title.en)}

Code snippet: deeply-localizing an entire document

Given a document with localized fields, this sometimes useful snippet will replace all localized fields with the specific entry for the desired language:

function localize(value, languages) {
  if (Array.isArray(value)) {
    return => localize(v, languages))
  } else if (typeof value == 'object') {
    if (/^locale[A-Z]/.test(value._type)) {
      const language = languages.find(lang => value[lang])
      return value[language]
    return Object.keys(value).reduce((result, key) => {
      result[key] = localize(value[key], languages)
      return result
    }, {})
  return value

It expects all localized fields to be named locale followed by an uppercase letter, matching localeString, localeBlocks, localeText or whatever you may come up with. Call it with a list of languages, like this: localize(myDocument, ['nb', 'en']). This will use the norwegian translation, but fall back to the english data wherever a translation is missing.

Previous: Importing DataNext: Extending