Files
johannes.gasser 57af0b8386
Build and Deploy / build-and-deploy (push) Successful in 2m53s
add skills
2026-05-18 08:39:42 +02:00

11 KiB

title, description
title description
Sanity Localization Rules Localization patterns for Sanity using official plugins and best practices.

Sanity Localization Rules

Use the contents list to jump directly to the localization pattern you need.

Table of Contents

  • Guiding principles
  • Terminology
  • Locale content type
  • Choosing document-level vs field-level localization
  • Document-level localization
  • Localized singletons
  • Field-level localization
  • AI-powered translation
  • UI enhancement
  • Frontend URL best practices

1. Guiding Principles

Priority: Easy Authoring Experience

The structured nature of Sanity schemas and GROQ make it easy to parse localized content for your frontend. Never let frontend architecture dictate your localization approach — prioritize the editor experience.

Avoid Content Duplication

Don't create nearly identical copies with slight differences (e.g., US vs British English). Use Portable Text marks and custom blocks to swap out words or sections as needed.

2. Terminology

Term Definition
Internationalization (i18n) Designing your frontend to support multiple languages
Localization Adapting content for a specific language/region
Language Tag Code like en, en-US, zh-Hant-TW (per IETF RFC 5646)
Locale A language tag with region info (e.g., en-US)

3. Create a Locale Content Type

Best Practice: Store locales in Sanity, not just in code. This allows sharing between Studio and frontend.

// schemaTypes/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', validation: (r) => r.required() }),
    defineField({ name: 'tag', type: 'string', description: 'IANA tag (en, en-US)', validation: (r) => r.required() }),
    defineField({ name: 'fallback', type: 'reference', to: [{ type: 'locale' }] }),
    defineField({ name: 'default', type: 'boolean' }),
  ],
  preview: { select: { title: 'name', subtitle: 'tag' } },
})

Tip: Restrict locale editing to admins via Structure by filtering locale from non-admin users.

4. Choose Your Localization Method

Content Type Examples Recommended Method
Structured (things) Products, People, Locations, Categories Field-level
Presentation (UI) Pages, Posts, Components Document-level

Decision Questions

  1. Are fields shared across languages? → Field-level
  2. Should changes be "global" for all locales? (e.g., reordering components) → Field-level
  3. Is content mostly the same except regional differences? → Field-level with PT marks
  4. Need to publish language versions independently? → Document-level

5. Document-Level Localization

Use the @sanity/document-internationalization plugin.

npm install @sanity/document-internationalization

Configuration

// sanity.config.ts
import { documentInternationalization } from '@sanity/document-internationalization'

export default defineConfig({
  plugins: [
    documentInternationalization({
      // Fetch from Content Lake
      supportedLanguages: (client) =>
        client.fetch(`*[_type == "locale"]{ "id": tag, "title": name }`),
      // Document types to localize
      schemaTypes: ['post', 'page'],
    }),
  ],
})

Add Language Field to Schema

// In each schema type listed in schemaTypes
defineField({
  name: 'language',
  type: 'string',
  readOnly: true,
  hidden: true,
})

Initial Value Templates

Pre-set language when creating documents outside the translation UI:

// sanity.config.ts
import { template } from 'sanity'

export default defineConfig({
  // ...
  document: {
    newDocumentOptions: (prev, { creationContext }) => {
      // Filter to only show base language in "New document" menu
      // The plugin handles creating translations from there
      return prev.filter((item) =>
        !['post', 'page'].includes(item.templateId) ||
        item.parameters?.language === 'en'
      )
    },
  },
  // Initial value templates for each language
  templates: (prev) => [
    ...prev,
    template.initial({
      id: 'post-en',
      title: 'Post (English)',
      schemaType: 'post',
      parameters: [{name: 'language', type: 'string'}],
      value: ({language}) => ({language}),
    }),
  ],
})

Querying Translated Documents

// Get document in specific language
*[_type == "post" && language == $locale && slug.current == $slug][0]

// Get all translations via metadata document
*[_type == "translation.metadata" && references($docId)][0] {
  translations[] {
    _key,
    value-> { title, slug, language }
  }
}

6. Localized Singletons (Homepage per Locale)

For singletons like homepages that need a separate document per locale, combine document-level localization with the singleton pattern.

Schema Definition

// schemaTypes/homePage.ts
import { HomeIcon } from '@sanity/icons'
import { defineType, defineField } from 'sanity'

export const homePageType = defineType({
  name: 'homePage',
  title: 'Home Page',
  type: 'document',
  icon: HomeIcon,
  fields: [
    defineField({
      name: 'language',
      type: 'string',
      readOnly: true,
      hidden: true,
    }),
    defineField({ name: 'title', type: 'string' }),
    defineField({ name: 'pageBuilder', type: 'pageBuilder' }),
    // ... other fields
  ],
  preview: {
    select: { language: 'language' },
    prepare({ language }) {
      return {
        title: 'Home Page',
        subtitle: language?.toUpperCase() || 'No language',
      }
    },
  },
})

Initial Value Templates

Create templates that pre-set the language for each locale:

// sanity.config.ts
import { defineConfig, Template } from 'sanity'

// Define your supported locales
const LOCALES = [
  { id: 'en', title: 'English' },
  { id: 'fr', title: 'French' },
  { id: 'de', title: 'German' },
]

export default defineConfig({
  // ...
  templates: (prev) => {
    // Create a template for each locale
    const homePageTemplates: Template[] = LOCALES.map((locale) => ({
      id: `homePage-${locale.id}`,
      title: `Home Page (${locale.title})`,
      schemaType: 'homePage',
      parameters: [{ name: 'language', type: 'string' }],
      value: { language: locale.id },
    }))

    return [...prev, ...homePageTemplates]
  },
})

Structure: Localized Singleton Helper

Create a helper to show one singleton per locale in the Structure:

// src/structure/index.ts
import { StructureBuilder, StructureResolver } from 'sanity/structure'
import { HomeIcon } from '@sanity/icons'

const LOCALES = ['en', 'fr', 'de']

function createLocalizedSingleton(
  S: StructureBuilder,
  typeName: string,
  title: string,
  icon?: React.ComponentType
) {
  return S.listItem()
    .title(title)
    .icon(icon)
    .child(
      S.list()
        .title(title)
        .items(
          LOCALES.map((locale) =>
            S.listItem()
              .title(`${title} (${locale.toUpperCase()})`)
              .icon(icon)
              .child(
                S.document()
                  .schemaType(typeName)
                  .documentId(`${typeName}-${locale}`) // Fixed ID per locale
                  .title(`${title} (${locale.toUpperCase()})`)
              )
          )
        )
    )
}

export const structure: StructureResolver = (S) =>
  S.list()
    .title('Content')
    .items([
      // Localized singletons
      createLocalizedSingleton(S, 'homePage', 'Home Page', HomeIcon),

      S.divider(),

      // Filter localized singletons from default list
      ...S.documentTypeListItems().filter(
        (item) => !['homePage'].includes(item.getId() as string)
      ),
    ])

Querying Localized Singletons

// Get homepage for specific locale
*[_type == "homePage" && language == $locale][0]{
  title,
  pageBuilder[]{...}
}

// Or by fixed document ID
*[_id == "homePage-" + $locale][0]{...}

Key Points

  • Fixed IDs: Use ${typeName}-${locale} pattern for predictable document IDs
  • Initial Value Templates: Essential for the "New document" menu to work correctly
  • Structure: Group all locale versions under one list item for cleaner navigation
  • See also: studio-structure.md for more singleton patterns

7. Field-Level Localization

Use sanity-plugin-internationalized-array (NOT localized objects — they hit attribute limits).

npm install sanity-plugin-internationalized-array

Configuration

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

export default defineConfig({
  plugins: [
    internationalizedArray({
      languages: (client) =>
        client.fetch(`*[_type == "locale"]{ "id": tag, "title": name }`),
      fieldTypes: ['string', 'text', 'simpleBlockContent'],
    }),
  ],
})

Usage in Schema

// The plugin creates types like `internationalizedArrayString`
defineField({
  name: 'jobTitle',
  type: 'internationalizedArrayString', // Localized string field
})

Portable Text Localization

Create a reusable block content type, then add it to fieldTypes:

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

// sanity.config.ts
fieldTypes: ['string', 'simpleBlockContent']

// In your schema
defineField({
  name: 'bio',
  type: 'internationalizedArraySimpleBlockContent',
})

Querying Internationalized Arrays

// Get specific locale value
*[_type == "author"][0] {
  "jobTitle": jobTitle[_key == $locale][0].value
}

// With fallback
*[_type == "author"][0] {
  "jobTitle": coalesce(
    jobTitle[_key == $locale][0].value,
    jobTitle[_key == "en"][0].value
  )
}

8. AI-Powered Translation

Use @sanity/assist for automated translations.

npm install @sanity/assist
// sanity.config.ts
import { assist } from '@sanity/assist'

export default defineConfig({
  plugins: [
    assist({
      translate: {
        // For document-level localization
        document: {
          languageField: 'language',
        },
        // For field-level localization
        field: {
          languages: (client) =>
            client.fetch(`*[_type == "locale"]{ "id": tag, "title": name }`),
          documentTypes: ['author', 'category'],
        },
      },
    }),
  ],
})

9. UI Enhancement

Use @sanity/language-filter to let editors show/hide locales:

npm install @sanity/language-filter

10. Frontend URL Best Practices

Always include locale in the URL for SEO:

  • yoursite.com/en/my-pageyoursite.com/fr/my-page
  • yoursite.com/my-page → redirects to default locale

Avoid: Having the default locale at root without prefix — causes SEO edge cases.

Use Next.js middleware (or framework equivalent) to redirect paths missing a locale prefix to the default locale.