--- title: Sanity Localization Rules description: 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. ```typescript // 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. ```bash npm install @sanity/document-internationalization ``` ### Configuration ```typescript // 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 ```typescript // 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: ```typescript // 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 ```groq // 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 ```typescript // 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: ```typescript // 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: ```typescript // 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 ```groq // 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). ```bash npm install sanity-plugin-internationalized-array ``` ### Configuration ```typescript // 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 ```typescript // 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`: ```typescript // 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 ```groq // 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. ```bash npm install @sanity/assist ``` ```typescript // 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: ```bash npm install @sanity/language-filter ``` ## 10. Frontend URL Best Practices **Always include locale in the URL** for SEO: - `yoursite.com/en/my-page` → `yoursite.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.