sanity-plugin-internationalized-array
v5.0.1
Published
Store localized fields in an array to save on attributes
Maintainers
Readme
sanity-plugin-internationalized-array
A plugin to register array fields with a custom input component to store field values in multiple languages, queryable by using a dedicated language field.

- sanity-plugin-internationalized-array
- Installation
- Usage for simple field types
- Loading languages
- Configuring the "Add translation" buttons
- Using complex field configurations
- Creating internationalized objects
- Validation of individual array items
- Usage with @sanity/language-filter
- Shape of stored data
- Querying data
- Migrate from v4 to v5
- Migrate from objects to arrays
- License
- Develop & test
- License
Installation
npm install --save sanity-plugin-internationalized-arrayor
yarn add sanity-plugin-internationalized-arrayUsage for simple field types
Add it as a plugin in sanity.config.ts (or .js):
import {defineConfig} from 'sanity'
import {internationalizedArray} from 'sanity-plugin-internationalized-array'
export default defineConfig({
// ...
plugins: [
internationalizedArray({
languages: [
{id: 'en', title: 'English'},
{id: 'fr', title: 'French'},
],
defaultLanguages: ['en'],
fieldTypes: ['string'],
}),
],
})This will register two new fields to the schema, based on the settings passed into fieldTypes:
internationalizedArrayStringan array field of:internationalizedArrayStringValuean object field, with a singlestringfield inside calledvalue
The above config will also create an empty array item in new documents for each language in defaultLanguages. This is configured globally for all internationalized array fields.
You can pass in more registered schema-type names to generate more internationalized arrays. Use them in your schema like this:
// For example, in postType.ts
fields: [
defineField({
name: 'greeting',
type: 'internationalizedArrayString',
}),
]Loading languages
Languages must be an array of objects with an id and title.
languages: [
{id: 'en', title: 'English'},
{id: 'fr', title: 'French'}
],Or an asynchronous function that returns an array of objects with an id and title.
languages: async () => {
const response = await fetch('https://example.com/languages')
return response.json()
}The async function contains a configured Sanity Client in the first parameter, allowing you to store Language options as documents. Your query should return an array of objects with an id and title.
languages: async (client) => {
const response = await client.fetch(`*[_type == "language"]{ id, title }`)
return response
},Additionally, you can "pick" fields from a document, to pass into the query. For example, if you have a concept of "Markets" where only certain language fields are required in certain markets.
In this example, each language document has an array of strings called markets to declare where that language can be used. And the document being authored has a single market field.
select: {
market: 'market'
},
languages: async (client, {market = ``}) => {
const response = await client.fetch(
`*[_type == "language" && $market in markets]{ id, title }`,
{market}
)
return response
},Configuring the "Add translation" buttons
The "Add translation" buttons can be positioned in one or multiple locations by configuring buttonLocations:
field(default) Below the internationalized array fieldunstable__fieldActionInside a Field Action (currently unstable)documentAbove the document fields, these buttons will add a new language item to every internationalized array field that can be found at the root of the document. Nested internationalized arrays are not yet supported.
To control the "Add translation" button titles, configure languageDisplay. This also affects language field labels.
codeOnly(default) Shows only the language codes (id)titleOnlyShows the language titletitleAndCodeShows the language title with the code in parentheses
The "Add all languages" button can be hidden with buttonAddAll.
import {defineConfig} from 'sanity'
import {internationalizedArray} from 'sanity-plugin-internationalized-array'
export default defineConfig({
// ...
plugins: [
internationalizedArray({
// ...other config
buttonLocations: ['field', 'unstable__fieldAction', 'document'], // default ['field']
buttonAddAll: false, // default true
languageDisplay: 'codeOnly', // codeOnly (default) | titleOnly | titleAndCode
}),
],
})Using complex field configurations
For more control over the value field, you can pass a schema definition into the fieldTypes array.
import {defineConfig} from 'sanity'
import {internationalizedArray} from 'sanity-plugin-internationalized-array'
export default defineConfig({
// ...
plugins: [
internationalizedArray({
languages: [
{id: 'en', title: 'English'},
{id: 'fr', title: 'French'}
],
fieldTypes: [
defineField({
name: 'featuredProduct',
type: 'reference',
to: [{type: 'product'}]
hidden: (({document}) => !document?.title)
})
],
})
]
})This would also create two new fields in your schema.
internationalizedArrayFeaturedProductan array field of:internationalizedArrayFeaturedProductValuean object field, with a singlestringfield inside calledvalue
Note that the name key in the field gets rewritten to value and is instead used to name the object field.
Creating internationalized objects
Due to how fields are created, you cannot use anonymous objects in the fieldTypes array. You must register the object type in your Studio's schema as an "alias type".
// ./schemas/seoType.ts
import {defineField} from 'sanity'
export const seoType = defineField({
name: 'seo',
title: 'SEO',
type: 'object',
fields: [
defineField({name: 'title', type: 'string'}),
defineField({name: 'description', type: 'string'}),
],
})Then in your plugin configuration settings, add the name of your alias type to the fieldTypes setting.
internationalizedArray({
languages: [
//...languages
],
fieldTypes: ['seo'],
})Lastly, add the field to your schema.
// ./schemas/post.ts
import {defineField, defineType} from 'sanity'
export default defineType({
name: 'post',
title: 'Post',
type: 'document',
fields: [
defineField({
name: 'seo',
type: 'internationalizedArraySeo',
}),
],
})Validation of individual array items
You may wish to validate individual language fields for various reasons. From the internationalized array field, add a validation rule that can look through all the array items, and return item-specific validation messages at the path of that array item.
defineField({
name: 'title',
type: 'internationalizedArrayString',
description: `Use fewer than 5 words.`,
validation: (rule) =>
rule.custom<{value: string; _type: string; _key: string}[]>((value) => {
if (!value) {
return 'Title is required'
}
const invalidItems = value.filter(
(item) => item.value.split(' ').length > 5,
)
if (invalidItems.length) {
return invalidItems.map((item) => ({
message: `Title is too long. Must be 5 words or fewer.`,
path: [{_key: item._key}, 'value'],
}))
}
return true
}),
}),Usage with @sanity/language-filter
If you have many languages and authors that predominately write in only a few, @sanity/language-filter can be used to reduce the number of language fields shown in the document form.
The plugin includes built-in integration with @sanity/language-filter.
To enable it, add languageFilter.documentTypes in the plugin config for the document types that should show the filter.
import {defineConfig} from 'sanity'
import {internationalizedArray} from 'sanity-plugin-internationalized-array'
export default defineConfig({
// ...
plugins: [
internationalizedArray({
languages: [
{id: 'en', title: 'English'},
{id: 'fr', title: 'French'},
],
defaultLanguages: ['en'],
fieldTypes: ['string'],
languageFilter: {
documentTypes: ['internationalizedPost', 'lesson'],
},
}),
],
})If you need more control, you can continue using @sanity/language-filter directly and pass internationalizedArrayLanguageFilter from this package as your filterField.
Shape of stored data
The custom input contains buttons which will add new array items with a random _key and the language stored in a dedicated language field. Data returned from this array will look like this:
"greeting": [
{ "_key": "abc123", "language": "en", "value": "hello" },
{ "_key": "def456", "language": "fr", "value": "bonjour" },
]Querying data
Using GROQ filters you can query for a specific language like so:
*[_type == "person"] {
"greeting": greeting[language == "en"][0].value
}Migrate from v4 to v5
v5 stores the language identifier on a dedicated language field instead of _key.
1. Backup your data.
You can manually backup your data using the sanity CLI.
sanity dataset export productionThis creates a production.tar.gz file in your current directory containing all your documents and assets.
You can also specify a custom filename and location:
sanity dataset export production ./backups/backup-2026-02-16.tar.gzIf you ever need to restore, use the import command:
sanity dataset import backup-2026-02-16.tar.gz productionOr you can use the backup service, read more at https://www.sanity.io/docs/content-lake/backups
2. Update your GROQ queries.
Use a backwards compatible query until your migration is ready and has been executed.
*[_type == "person"] {
- "greeting": greeting[_key == "en"][0].value
+ "greeting": greeting[language == "en" || _key == "en"][0].value
}If you use AI agents in your code, you can copy this skill to your project to guide your agents and help you with the migration.
Use this pre migration prompt
Use the project skill `i18n-array-groq-query-migration` to update this repo's GROQ queries for `sanity-plugin-internationalized-array` v5.
Goal:
- Detect all query patterns where language is read from `_key` (for example `_key == "en"` or `_key == $language`).
- Keep the same language expression used in each query.
Mode: PRE-MIGRATION (backwards-compatible)
- Replace `_key == <languageExpr>` with `language == <languageExpr> || _key == <languageExpr>`.
What to do:
1) Scan the codebase for all relevant GROQ queries.
2) Update the files in place.
3) Exclude unrelated `_key` uses that are not language matching.
4) Return a concise report with:
- files changed
- before/after snippets
- any ambiguous matches needing manual review.3. Data migration
The package exports a migration helper that you can run with the migration CLI.
Create a migration file in your project and export it:
// ./migrations/migrateToLanguageField.ts
import {migrateToLanguageField} from 'sanity-plugin-internationalized-array/migrations'
// Add the document types that contain internationalized arrays.
// Example: ['internationalizedPost', 'translation.metadata']
const DOCUMENT_TYPES: string[] = []
export default migrateToLanguageField(DOCUMENT_TYPES)Then verify your migration with a dry run:
pnpm sanity migration run migrateToLanguageFieldOnce ready, run the migration:
pnpm sanity migration run migrateToLanguageField --no-dry-runIf you use @sanity/document-internationalization: Include 'translation.metadata' in your migration's document types so that the translations array on metadata documents is migrated
And update @sanity/document-internationalization to v6
4. Update your GROQ queries
Previously we updated the GROQ queries to support both locations for the language field. Once migration is complete, update the GROQ queries again to only use language and remove the dependency on _key.
*[_type == "person"] {
"greeting": greeting[language == "en"][0].value
}If you use AI agents in your code, you can automate this with the project skill at .cursor/skills/i18n-array-groq-query-migration/SKILL.md.
Ask your AI agent to help you
# Migrate internationalized-array to v5 and document-internationalization to v6
Create and present a plan first, then wait for user confirmation before making changes.
Before running any migration steps, ask the user to create a backup and confirm it is complete. Do not proceed until backup confirmation is received.
Use this context:
- Document internationalization (v5 -> v6): https://github.com/sanity-io/plugins/blob/main/plugins/%40sanity/document-internationalization/README.md#migrating-to-v6
- Internationalized array README: https://github.com/sanity-io/plugins/blob/main/plugins/sanity-plugin-internationalized-array/README.md
- Skill source to copy from: https://github.com/sanity-io/plugins/blob/main/.claude/skills/i18n-array-groq-query-migration/SKILL.md
Then execute this workflow:
## Pre migration:
1) Copy the `i18n-array-groq-query-migration` skill file from the provided source URL into the relevant local project skill location.
2) Use the project skill `i18n-array-groq-query-migration` in PRE-MIGRATION mode and update all legacy GROQ filters from:
`_key == <languageExpr>`
to:
`language == <languageExpr> || _key == <languageExpr>`
3) Keep the same `<languageExpr>` in each query, edit files in place, and report changed files plus any ambiguous cases.
4) Add a pause here, the user should deploy this changes with the queries updated before continuing with the migration, request confirmation on this step.
## Migration:
5) Update the packages to the new versions.
6) Identify schema types that need updates by scanning schemas where internationalization is used, specifically document types that contain fields with `type` matching `internationalizedArray*`.
7) Create the migration file following the internationalized array migration guidance, using the discovered document types in the previous step.
8) If `@sanity/document-internationalization` is used, include `translation.metadata` in the migration document types and plan for upgrading `@sanity/document-internationalization` to `v6`.
9) Ask the user to run the migration as dry run first, then non-dry-run only after confirmation.
10) Stop and ask the user to confirm the migration completed successfully before proceeding to post-migration query cleanup.
11) Ask the user to deploy the changes.
## Post migration.
11) After migration is confirmed complete, use the project skill in POST-MIGRATION mode.
12) Update all GROQ queries from:
`language == <languageExpr> || _key == <languageExpr>`
to:
`language == <languageExpr>`
13) Keep the same `<languageExpr>` in each query, edit files in place, and report changed files plus any ambiguous cases.
Report:
- The approved plan
- Backup confirmation status
- Changed query files
- Discovered schema types for migration
- The created migration file path and summary of what it migratesUsage with language filter
The plugin now includes built-in integration with @sanity/language-filter.
To enable it, add languageFilter.documentTypes in the plugin config for the document types that should show the filter.
import {defineConfig} from 'sanity'
import {internationalizedArray} from 'sanity-plugin-internationalized-array'
export default defineConfig({
// ...
plugins: [
internationalizedArray({
languages: [
{id: 'en', title: 'English'},
{id: 'fr', title: 'French'},
],
defaultLanguages: ['en'],
fieldTypes: ['string'],
languageFilter: {
documentTypes: ['internationalizedPost', 'lesson'],
},
}),
],
})Migrate from objects to arrays
See the migration script inside ./migrations/transformObjectToArray.ts of this plugin.
Follow the instructions inside the script and set the _type and field name you wish to target.
Please take a backup first!
Why store localized field data like this?
The most popular way to store field-level translated content is in an object using the method prescribed in @sanity/language-filter. This works well and creates tidy object structures, but also creates a unique field path for every unique field name, multiplied by the number of languages in your dataset.
For most people, this won't become an issue. On a very large dataset with a lot of languages, the Attribute Limit can become a concern. This plugin's arrays will use fewer attributes than an object once you have more than three languages.
The same content as above, plus a third language, structured as an object of string fields looks like this:
"greeting" {
"en": "hello",
"fr": "bonjour",
"es": "hola"
}Which creates four unique query paths, one for the object and one for each language.
greeting
greeting.en
greeting.fr
greeting.esEvery language you add to every object that uses this structure will add to the number of unique query paths.
The array created by this plugin creates four query paths by default, but is not affected by the number of languages:
greeting
greeting[]
greeting[]._key
greeting[].valueBy using this plugin you can safely extend the number of languages without adding any additional query paths.
License
MIT © Simeon Griggs
