# Course: Localization
https://www.sanity.io/learn/course/localization

Learn the common approaches to localizing content in Sanity

---

## Navigation

## Contents

1. [Introduction](https://www.sanity.io/learn/course/localization/introduction) · [markdown](https://www.sanity.io/learn/course/localization/introduction.md)
2. [Creating a locale content type](https://www.sanity.io/learn/course/localization/creating-a-locale-content-type) · [markdown](https://www.sanity.io/learn/course/localization/creating-a-locale-content-type.md)
3. [Determining what localization method to use](https://www.sanity.io/learn/course/localization/determining-what-localization-method-to-use) · [markdown](https://www.sanity.io/learn/course/localization/determining-what-localization-method-to-use.md)
4. [Implementing document level localization](https://www.sanity.io/learn/course/localization/implementing-document-level-localization) · [markdown](https://www.sanity.io/learn/course/localization/implementing-document-level-localization.md)
5. [Implementing field level localization](https://www.sanity.io/learn/course/localization/implementing-field-level-localization) · [markdown](https://www.sanity.io/learn/course/localization/implementing-field-level-localization.md)
6. [Implementing AI Assist translations](https://www.sanity.io/learn/course/localization/implementing-ai-assist-translations) · [markdown](https://www.sanity.io/learn/course/localization/implementing-ai-assist-translations.md)

---

## Lesson 1: Introduction
https://www.sanity.io/learn/course/localization/introduction

Learn what this course will cover

In this course we'll walk through how you can localize your content in Sanity in a way that ensures an organized experience for content authors. We'll cover:



- Creating a source of truth for your locales with a `locale` content type

- Selecting the right localization method based on your content model

- Implementing both localization methods in Sanity's blog starter

- Implementing AI Assist for automated translation of content


## Terminology



There are some terms worth understanding when talking about localization



- **Internationalization (i18n)** - The process of designing and developing your front-end to allow it to be viewed in multiple languages.

- **Localization** - The actual adaptation of your content and front-end UI to be shown in a specific language.

- **Language tag** - As defined in the [IETF RFC 5646](https://datatracker.ietf.org/doc/html/rfc5646) (a specification for codes to localize content), a language tag is a full string representing the language (and optionally region and script) for a given piece of content. “zh”, “zh-CN”, and “zh-CN-Hant” are all examples of language tags.

- **Subtag** - Language subtags are the sections of a language tag delineated by a hyphen. Using the previous example “zh”, “CN”, and “Hant” are all subtags of the language tag “zh-CN-Hant”. As you can also see in the above example, sometimes a single subtag defines a full language tag. There are 3 different types of subtag - language, region, and script, and language tags are constructed with the subtags in that order.

- **Locale** - Locale is a helpful descriptor for a language tag containing both language and region information.


## Guiding principles



### The priority should be an easy authoring experience



The structured nature of Sanity’s schemas and the flexibility of the GROQ query language make it easy to parse and filter localized content for consumption within a front-end; as such there are few if any cases where your localization approach should be decided to satisfy the needs of your front-end architecture.



### Avoid duplicates of content



It’s a common pitfall to end up with several nearly identical copies of the same pieces of content with very slight differences between them. For example, content in US English and British English, where most of the content is the same except for slight spelling differences, or legal copy that is shared across the US and Canada, but with a few paragraphs excluded in the Canadian version. Portable Text makes it easy to re-use as much content as possible, while using marks and custom blocks to swap out words or entire sections as-needed.







With that in mind, let's look at creating a `locale` content type



---

## Lesson 2: Creating a locale content type
https://www.sanity.io/learn/course/localization/creating-a-locale-content-type

Creating a re-usable content type for your locale information

When implementing localization it’s helpful to be able to share your list of locales between the Studio and your front-end.



If your Studio and front-end code are located in the same repository you could have a file declaring your locales, but when multiple repositories depend on the same locale information it can be beneficial to have that information stored in Sanity.



First let’s understand what information is helpful when defining locales.



## Structure of a locale



### The language tag



As outlined previously, language tags are most commonly 2-letter codes used to identify a language (ex. `en` for English or `es` for Spanish). When needing to delineate a language belonging to a specific country, a capitalized 2-letter [*region subtag*](https://www.rfc-editor.org/rfc/rfc5646#section-2.2.4) is added to the language with a hyphen (-), for example `en-US` vs `es-US` vs `es-ES`. The combination of a language and region tag is commonly referred to as a *locale*.



### Human-readable name



Human-readable names help enhance UI dependent on locale information (authors can see locale information at a glance in Studio, end users can select from a list of names - not language codes, etc)



### Fallbacks



Fallbacks specify what locale to show a user when content in their selected locale isn’t available, for example serving a user `fr-FR` (French as spoken in France) when `fr-CA` (Canadian French) doesn’t have specific translations.



## Storing locale information locally



For your needs it may be enough to define a constant containing your locale information you share between your front-end and Studio. 



Sanity's localization plugins expect locale information in the following format:



```typescript
const locales = [
  {id: 'en-US', title: 'English (US)'}
]
```

As you can see, the tag is called `id` and the human-readable name is called `title`. It's worth noting that there's no standard set of properties used for locale objects across all the various i18n tooling out there, so the need may arise to map through the array of locales you use to match the format expected by your tooling.



## Creating a schema



Because your front-end and Studio localization plugins expect slightly different formats for the locale objects, let's model a locale based on the logical structure of a locale (outlined above) and then use GROQ to fetch locale data in the needed structure.



```typescript:locale.ts
// ./src/schema-types/locale.ts
import {TranslateIcon} from '@sanity/icons'
import {defineField, defineType} from 'sanity'

export const localeType = defineType({
  name: 'locale',
  icon: TranslateIcon,
  type: 'document',
  fields: [
    defineField({
      name: 'name',
      type: 'string',
      description: 'The name of the language/locale, in the specified language.',
      validation: (Rule) => Rule.required(),
    }),
    defineField({
      name: 'tag',
      type: 'string',
      description: 'The tag of the language or locale.',
      validation: (Rule) =>
        Rule.required()
          .regex(
            /^[a-z]{2,3}(?:-[A-Z][a-z]{3})?(?:-(?:[A-Z]{2}|\d{3}))?(?:-[a-zA-Z0-9]{5,8}|-[0-9][a-zA-Z0-9]{3})*$/,
            {
              name: 'IANA language tag',
              invert: false,
            },
          )
          .error('Must be a valid IANA language tag (e.g., en, en-US, zh-Hant-TW)'),
    }),
    defineField({
      name: 'fallback',
      type: 'reference',
      description: "Locale to show if content isn't available in this locale.",
      to: [{type: 'locale'}],
    }),
    defineField({
      name: 'default',
      type: 'boolean',
      description: 'Is this the default locale?',
      // Ensure only 1 default locale is set
      validation: (Rule) =>
        Rule.custom(async (value, context) => {
          if (!value) return true // If not set to true, no validation needed

          const {getClient} = context
          const client = getClient({apiVersion: '2025-04-14'})

          const existingDefault = await client.fetch(
            `*[_type == "locale" && default == true && _id != $id][0]`,
            {id: context?.document?._id},
          )

          return existingDefault
            ? `Only one locale can be set as the default. ${existingDefault.tag} currently set.`
            : true
        }),
    }),
  ],
  preview: {
    select: {
      title: 'name',
      subtitle: 'tag',
    },
  },
})
```

## Hiding `locale` documents in the Structure tool



You probably don’t want your locale settings to be seen/edited by all users of your Studio. Here’s an example illustrating how to only show the language documents for administrators:



```typescript:structure.ts
// ./src/structure.ts
import type {StructureResolver} from 'sanity/structure'

export const structure: StructureResolver = (S, context) => {
  const {currentUser} = context
  const isAdmin = currentUser?.roles.some((role) => role.name === 'administrator')

  return S.list()
    .title('Content')
    .items([
      S.listItem()
        .title('Site settings')
        .child(S.editor().schemaType('siteSettings').documentId('siteSettings')),
      // Add a visual divider (optional)
      S.divider(),
      // List out the rest of the document types, but filter out the config type
      ...S.documentTypeListItems().filter((listItem) => {
        if (['locale'].includes(listItem.getId()!)) {
          return isAdmin
        }
        return true
      }),
    ])
}

```

## A note on `default`



Most i18n front-end libraries will ask for a default. In most cases the default determines what locale to fall back to when a user’s language isn’t present.



When using [Next.js’ `next.config` i18n settings](https://nextjs.org/docs/pages/building-your-application/routing/internationalization), or [Nuxt’s `no_prefix`](https://i18n.nuxtjs.org/docs/guide#no_prefix) mode the default locale will have an impact on the routing behavior of your front-end. When using these configurations the `default` locale will live at [`yoursite.com/my-page`](http://yoursite.com/my-page) but the non-default locale will exist at `yoursite.com/LANGUAGE_CODE/my-page`. This behavior may be desired for your use case, but we encourage you to have the locale code in the path for every locale, and routes without a language code redirect to that path under the default language. The reason for recommending every path include a locale is two fold: we've seen edge cases pop up from the differences in path behavior, and [including the locale in the URL helps with SEO ranking in those specific markets](https://developers.google.com/search/docs/specialty/international/managing-multi-regional-sites#use-different-urls-for-different-language-versions).







Following the guidance above would mean if `en` was our default language our site would have:



- `yoursite.com/en/my-page` for English

- `yoursite.com/fr/my-page` for French

- `yoursite.com/my-page` redirects to `yoursite.com/en/my-page`


---

## Lesson 3: Determining what localization method to use
https://www.sanity.io/learn/course/localization/determining-what-localization-method-to-use

Learn how to find the right localization method for your schemas

Sanity provides two methods for localizing content: field localization and document localization. Both methods can be mixed and matched to best suit your content model, and which method you use is often a direct result of how your content model is structured.



## Document level localization



Document level localization is done via the [document-internationalization plugin](https://github.com/sanity-io/document-internationalization). The `document-internationalization` plugin creates copies of a document, one for each language the document is translated to. The plugin also creates a `translation.metadata` document containing references to all the translated versions.



## Field level localization



Field level localization is done via [sanity-plugin-internationalized-array](https://github.com/sanity-io/sanity-plugin-internationalized-array). The internationalized array plugin allows you to localize on a field-by-field basis, meaning some fields can be shared across languages, while others can have localized versions.



> [!WARNING]
> Why not use localized objects? Localized objects can cause you to hit Sanity's attribute limit, as each locale property in an object counts as a new attribute, whereas [arrays have a set number of attributes](https://www.sanity.io/docs/localization#8fd937ba48e5).



## Which method to use for each content type



There are two general categories for the type of content schemas you declare in Sanity: *structured* content, where you model *things* like products, people, locations, or categories; and *presentation* content, where you model parts of your front-end UI to be controlled within the CMS like components, pages, or posts.



A general rule of thumb is that *structured* content often works best with field localization, whereas *presentation* content often dictates the use of document localization.



### Questions for finding the right method



- What’s different between the translated versions of this content?

- Are there fields that don’t change between languages? - Field level

- When should changes be “global” for all locales? Ex. I want to re-order components on the “en-US” version of a page and have that reflected in all locales. In this example things that are “global” aren’t localized, but their nested fields are - Field level

- Will this approach end up in needless duplicates of content?

- Is the content largely the same except for regional differences? - Field level inside Portable Text marks and blocks

- Do I need to create, edit, and publish language versions independent from one another? In Sanity you can't publish a subset of fields within a document, only the document itself - Document level


---

## Lesson 4: Implementing document level localization
https://www.sanity.io/learn/course/localization/implementing-document-level-localization

Add document level localization to an existing schema

Document level localization can be achieved using the [@sanity/document-internationalization](https://github.com/sanity-io/document-internationalization) plugin. The following examples will be using the blog starting schemas you can select when running `npx sanity init` but can be applied to any schema.



To start install the plugin:



```sh
npm install --save @sanity/document-internationalization
```

Then add the plugin to your plugins array in sanity.config.ts



```typescript:sanity.config.ts
// ./sanity.config.ts
// ...rest of imports
import {documentInternationalization} from '@sanity/document-internationalization'

export default defineConfig({
  // ...rest of config
 
  plugins: [
    // ...rest of plugins
    documentInternationalization({
      // fetch locales from Content Lake or load from your locale file
      supportedLanguages: (client) => client.fetch(`*[_type == "locale"]{"id": tag, "title":name}`),
      // define schema types using document level localization
      schemaTypes: ['post'],
    }),
  ],
	// ...rest of config
})

```

The configuration for `documentInternationalization` above includes:



- `supportedLanguages` - exposes a Sanity client we can use to get the locale documents created in the previous step. Alternatively you can pass it a static array of objects from your codebase containing an `id` representing the locale code and `title` representing the name of the locale

- `schemaTypes` - specifies which schema types we want to use for document level localization


Finally we need to add the `language` field to all the types declared in `schemaTypes`. So in our `post` schema declaration we add the following:



```typescript
defineField({
  name: 'language',
  type: 'string',
  readOnly: true,
  hidden: true,
})
```

With the plugin configured and fields added to our schema types, we can now see the dropdown in the Studio for the post content type allowing us to create and manage localized copies of each document.



![Screenshot of menu to manage document translations](https://cdn.sanity.io/images/3do82whm/next/473db9cf33f49767a813750f44e1ed434bb9a35c-2002x1370.png)

---

## Lesson 5: Implementing field level localization
https://www.sanity.io/learn/course/localization/implementing-field-level-localization

Adding field-level localization to an existing schema type

Field level localization is handled by `sanity-plugin-internationalized-array`



First, install the plugin:



```sh
npm install --save sanity-plugin-internationalized-array
```

Then add the plugin to your sanity.config.ts file:



```typescript:sanity.config.ts
// ./sanity.config.ts
import {internationalizedArray} from 'sanity-plugin-internationalized-array'

export default defineConfig({
	// ...rest of config
  plugins: [
	  // ...rest of plugins
    internationalizedArray({
	    // Use client to fetch locales or import from local locale file
      languages: (client) => client.fetch(`*[_type == "locale"]{"id": tag, "title":name}`),
      // Define field types to localize as-needed
      fieldTypes: ['string'],
    }),
  ],
  // ...rest of config
})
```

The options passed in that object are:



- `languages` - Similar to `supportedLanguages` in `document-internationalization` , a provided Sanity client allows us to fetch the locale documents from the Content Lake, or a local constant can be used.

- `fieldTypes` - The field types to create internationalized arrays of.


In the `author` schema type from the blog starting template, add a new field for `jobTitle` with the type `internationalizedArrayString`



```typescript:author.ts
// ./schemaTypes/author.ts
import {defineField, defineType} from 'sanity'

export default defineType({
  name: 'author',
  title: 'Author',
  type: 'document',
  fields: [
    // ...rest of fields
    defineField({
      name: 'jobTitle',
      type: 'internationalizedArrayString',
    }),
    // ...rest of fields
  ],
})
```

Now we have a translated field for our author’s job titles:



![Image](https://cdn.sanity.io/images/3do82whm/next/cfb7573017bb71cc3adfdd8e842586a29c10a921-2000x1518.png)

## Field level localization with Portable Text



To localize Portable Text fields, you first need to create a reusable type for your array of blocks that can be passed by name to the `fieldTypes` property in the plugin configuration. In the blog starter there’s already a `blockContent` type, but to illustrate the full process we’ll take the Portable Text field used in for the `author` type’s `bio` field and make it its own definition.



First create a new file in `schemaTypes` called `simpleBlockContent` with the following:



```typescript:simpleBlockContent.ts
// ./schemaTypes/simpleBlockContent.ts
import {defineType} from 'sanity'

export default defineType({
  name: 'simpleBlockContent',
  type: 'array',
  of: [
    {
      title: 'Block',
      type: 'block',
      styles: [{title: 'Normal', value: 'normal'}],
      lists: [],
    },
  ],
})

```

Once you add it to the exported types in schemaTypes/index.ts , you can use it in the internationalizedArray plugin config like so:



```typescript:sanity.config.ts
// ./sanity.config.ts
import {internationalizedArray} from 'sanity-plugin-internationalized-array'

export default defineConfig({
	// ...rest of config
  plugins: [
	  // ...rest of plugins
    internationalizedArray({
	    // Use client to fetch locales
      languages: (client) => client.fetch(`*[_type == "locale"]{"id": tag, "title":name}`),
      // Define field types to localize as-needed
      fieldTypes: ['string', 'simpleBlockContent'],
    }),
  ],
  // ...rest of config
})
```

Finally in your author type definition, update the bio field to use the new internationalized type:



```typescript:author.ts
import {defineField, defineType} from 'sanity'

export default defineType({
  name: 'author',
  title: 'Author',
  type: 'document',
  fields: [
    // ...rest of fields
    defineField({
      name: 'bio',
      title: 'Bio',
      type: 'internationalizedArraySimpleBlockContent',
    }),
  ],
})

```

> [!WARNING]
> When localizing Portable Text it is strongly recommended to use the `internationalized-array` plugin (*not* object-based field-level localization) to avoid hitting Sanity's attribute limit



## Enhancing the UI



For Studios with several locales the UI can quickly become overwhelming. The [language-filter plugin](https://github.com/sanity-io/language-filter) allows users to show and hide locales to simplify the UI.



---

## Lesson 6: Implementing AI Assist translations
https://www.sanity.io/learn/course/localization/implementing-ai-assist-translations

[AI Assist’s content translations](https://www.sanity.io/docs/ai-assist-content-translation) enable automated translation of your documents, and can be used with both document and field-level localization



First install AI Assist



```sh
npm install @sanity/assist
```

And add the plugin to your sanity.config.ts file



```typescript:sanity.config.ts
// sanity.config.ts
import { defineConfig } from 'sanity'
import { assist } from '@sanity/assist'
/* other imports */

export default defineConfig({
  /* other config */
  plugins: [
    /* other plugins */
    assist(),
  ]
})
```

You’ll be prompted to enable AI Assist from the Studio once added:



![Image](https://cdn.sanity.io/images/3do82whm/next/7ad32f5d096f0f496ff2c1b988ac0bd60c172a49-1440x620.png)

## Document-level localizations



To begin enabling translations with `@sanity/assist` add a `translate` object to the plugin configuration. The `translate.document` property specifies the settings for document-level localization:



```typescript:sanity.config.ts
// sanity.config.ts
import { defineConfig } from 'sanity'
import { assist } from '@sanity/assist'
/* other imports */

export default defineConfig({
  /* other config */
  plugins: [
    /* other plugins */
    assist({
	    translate: {
				document: {
					// Specify the field containing the language for the document
					languageField: ['language'],
				}
	    }
    }),
  ]
})
```

## Field-level localizations



For field-level localization with AI Assist, we specify a `field` object with the following configuration options:



- `languages` - similar to the definitions for the `documentInternationalization` plugin and `internationalizedArray` plugin, we can use a provided Sanity client to fetch our `locale` documents

- `documentTypes` - Define the document types leveraging fields created by `internationalizedArray`


```typescript:sanity.config.ts
// sanity.config.ts
import { defineConfig } from 'sanity'
import { assist } from '@sanity/assist'
/* other imports */

export default defineConfig({
  /* other config */
  plugins: [
    /* other plugins */
    assist({
	    translate: {
				document: {
					// Specify the field containing the language for the document
					languageField: ['language'],
				},
				field: {
				  languages: (client) => client.fetch(`*[_type == "locale"]{"id": tag, "title":name}`),
					documentTypes: ['post']
				}
	    }
    }),
  ]
})
```

With those configurations updated, you should now have the option to translate the entire document or the localized fields from the top AI Assist menu in the document editor.



![Image](https://cdn.sanity.io/images/3do82whm/next/0702c65c3d63687150c4240de8e90ed3394b9a3d-1258x704.png)

![Image](https://cdn.sanity.io/images/3do82whm/next/676519a29dd0aa2bfd366fb21937314644ab487d-1250x1262.png)

---

## Related Resources

- [All courses and lessons](https://www.sanity.io/learn/sitemap.md)
- [Complete content for LLMs](https://www.sanity.io/learn/llms-full.txt)
