Skill 05 · Sanity Best Practices
Subchapter 5.10
references/localization.mdMarkdown12 KBView on GitHub
Use the contents list to jump directly to the localization pattern you need.
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.
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.
| 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) |
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/Translate'
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.
| Content Type | Examples | Recommended Method |
|---|---|---|
| Structured (things) | Products, People, Locations, Categories | Field-level |
| Presentation (UI) | Pages, Posts, Components | Document-level |
Use the @sanity/document-internationalization plugin.
npm install @sanity/document-internationalization// 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'],
}),
],
})// In each schema type listed in schemaTypes
defineField({
name: 'language',
type: 'string',
readOnly: true,
hidden: true,
})Pre-set language when creating documents outside the translation UI:
// sanity.config.ts
import { defineConfig } from 'sanity'
import type { Template } from 'sanity'
const LOCALIZED_TYPES = ['post', 'page']
const BASE_LANGUAGE = 'en'
export default defineConfig({
// ...
document: {
newDocumentOptions: (prev) => [
// Drop the auto-generated entries for localized types — they create
// documents with no `language` set
...prev.filter((item) => !LOCALIZED_TYPES.includes(item.templateId)),
// Offer the base-language templates instead
// The plugin handles creating translations from the document itself
...LOCALIZED_TYPES.map((schemaType) => ({
templateId: `${schemaType}-${BASE_LANGUAGE}`,
parameters: {language: BASE_LANGUAGE},
})),
],
},
schema: {
// A base-language template per localized type
templates: (prev): Template[] => [
...prev,
...LOCALIZED_TYPES.map((schemaType) => ({
id: `${schemaType}-${BASE_LANGUAGE}`,
title: `${schemaType} (${BASE_LANGUAGE})`,
schemaType,
parameters: [{name: 'language', type: 'string'}],
value: ({language}: {language: string}) => ({language}),
})),
],
},
})A template that declares parameters is left out of the auto-generated “New
document” list, so it only appears if newDocumentOptions adds it explicitly,
with the parameter values supplied on the item. The auto-generated items carry
no parameters of their own, so a filter that tests item.parameters matches
nothing and empties the menu.
// 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 }
}
}For singletons like homepages that need a separate document per locale, combine document-level localization with the singleton pattern.
// schemaTypes/homePage.ts
import { HomeIcon } from '@sanity/icons/Home'
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',
}
},
},
})Create templates that pre-set the language for each locale:
// sanity.config.ts
import { defineConfig } from 'sanity'
import type { Template } from 'sanity'
// Define your supported locales
const LOCALES = [
{ id: 'en', title: 'English' },
{ id: 'fr', title: 'French' },
{ id: 'de', title: 'German' },
]
export default defineConfig({
// ...
schema: {
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]
},
},
})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/Home'
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)
),
])// Get homepage for specific locale
*[_type == "homePage" && language == $locale][0]{
title,
pageBuilder[]{...}
}
// Or by fixed document ID
*[_id == "homePage-" + $locale][0]{...}${typeName}-${locale} only for localized singletons; let Sanity generate IDs for ordinary localized contentstudio-structure.md for more singleton patternsUse sanity-plugin-internationalized-array (NOT localized objects — they hit attribute limits).
npm install sanity-plugin-internationalized-array// 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'],
}),
],
})// The plugin creates types like `internationalizedArrayString`
defineField({
name: 'jobTitle',
type: 'internationalizedArrayString', // Localized string field
})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',
})// 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
)
}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'],
},
},
}),
],
})Use @sanity/language-filter to let editors show/hide locales:
npm install @sanity/language-filterAlways include locale in the URL for SEO:
yoursite.com/en/my-page → yoursite.com/fr/my-pageyoursite.com/my-page → redirects to default localeAvoid: 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.