Locale: No documented example of adding locale for model with block collection field – Structured Text / Modular Content

Is there a way to update (nested) modular content blocks?

We’re in the process of adopting localization and are aware that modular content fields (block collections) cannot be localized on a block-level. This makes a whole lot of sense from a data-modeling perspective.

However, it’s unclear how to update a localized field that contains a block like Structured Text, or contains a collection (ordered array of blocks).

I’ve read the example docs:

Context: My project is currently in english only and I want to add German. I add de to project settings, add locale support for individual model fields.

I want to add de programmatically to records via CMA. How do I do this? Block collection content is referenced by ID, so I cannot simply copy the hash returned in the English en key and plug it into de.

I suspect I need functionality depicted the GUI when adding a locale “Copy content from English?”. Is this functionality available via CMA?

The subroutine executed by the modal seems to be the only sane way to add an alternative locale to a block (collection) field.

If I had to imagine what is necessary to add (or update) a locale for a record which features a modular content collection field:

  • Given record with block (collection) field
  • Add record to locale via GUI, unless the functionality exists somewhere undocumented in CMA
    • this duplicates all the blocks buried within fields, creating new IDs for said blocks
  • Programmatically fetch records, when de key is already present on block collection field, traverse the tree and translate strings buried in Structured Text / Modular Content from ende
  • Update the record via client.items.update after carefully extracting the blockId (though unclear because haven’t seen guidance on locales)
  • loop through to-be-updated blocks and perform client.items.update(recordId, { [fieldWithBlocks]: updatedBlocks })

I’ve tried this and the API falls over when it encounters non-trivial modular content:

"seo_description_structured": {
  "de": {
    "schema": "dast",
    "document": {
      "children": [
        {
          "children": [
            {
              "type": "span",
              "value": "[translated-value-de]The fastest way for businesses to send rewards at scale. For free."
            }
          ],
          "type": "paragraph"
        }
      ],
      "type": "root"
    }
  },
  "en": {
    "schema": "dast",
    "document": {
      "children": [
        {
          "children": [
            {
              "type": "span",
              "value": "The fastest way for businesses to send rewards at scale. For free."
            }
          ],
          "type": "paragraph"
        }
      ],
      "type": "root"
    }
  }
}

children and document yield a 422 rejection as invalid attributes

{
  "id": "da2ad4",
  "type": "api_error",
  "attributes": {
    "code": "INVALID_ATTRIBUTES",
    "details": {
      "extraneous_attributes": [
        "children",
        "document"
      ]
    },
    "doc_url": "https://www.datocms.com/docs/content-management-api/errors#INVALID_ATTRIBUTES"
  }
}

Thanks for any help!

Hi @drew,

Thank you for this detailed question and sorry about the unclear docs! I’ll update them ASAP. In the meantime, though, here is some sample code that will hopefully help.

Given a record like this:

This code should let you add a German locale while keeping the English contents intact:

import {buildBlockRecord, buildClient, LogLevel} from "@datocms/cma-client";
import type {Document as StructuredTextDocument} from 'datocms-structured-text-utils';
import type {SimpleSchemaTypes} from "@datocms/cma-client-node";

// The actual update code
(async () => {
    const client = buildClient({
        apiToken: process.env.DATO_API_TOKEN,
        logLevel: LogLevel.BODY // Shows you the HTTP bodies being sent and received, to make it easier to understand
    })

    const recordId = 'YtTI-MSMS2246DqXHet0jA' // The specific record ID you want to update
    const blockItemTypeId = 'OI-LIDMeQ4a7KkgFO7DIUQ'; // The block's item TYPE id, which you can get from its URL or schema

    const currentRecord: ExampleRecord = await client.items.find(recordId, {
            nested: true // IMPORTANT: Retrieves existing block CONTENTS instead of just their IDs
        }
    )

    // console.log(currentRecord)
    /* Returns something like:
    {
      id: 'YtTI-MSMS2246DqXHet0jA',
      type: 'item',
      title: { en: 'English title' },
      modular_content_field: { en: [ [Object], [Object] ] },
      structured_text_field: { en: { schema: 'dast', document: [Object] } },
      item_type: { id: 'IdfCc2k-RCWrlGVfbwYpBA', type: 'item_type' },
      ...
    } */

    const newBlockContent: ExampleBlockContents = {
        item_type: {type: 'item_type', id: blockItemTypeId},
        paragraph_field_inside_block: 'Beispiel-Block auf Deutsch'
    }

    const newTranslatedBlock = buildBlockRecord( newBlockContent)

    const newTranslatedStructuredText: StructuredTextDocument = {
        "schema": "dast",
        "document": {
            "type": "root",
            "children": [
                {
                    "type": "paragraph",
                    "children": [
                        {
                            "type": "span",
                            "value": "Deutsch in Strukturiertem Text"
                        }
                    ]
                },
                {
                    type: "block",
                    item: newTranslatedBlock as unknown as string, // This is a bug in our types, I think... it should accept a new block record instead of an ID
                }
            ]
        }
    }

    const updatedRecordContent: ExampleRecord = {
        ...currentRecord,
        title: {
            ...currentRecord.title, // Destructure & copy existing locales
            de: 'Beispieltext auf Deutsch' // Add new German string
        },
        modular_content_field: {
            ...currentRecord.modular_content_field, // Destructure & copy existing locales
            de: [newTranslatedBlock]
        },
        structured_text_field: {
            ...currentRecord.structured_text_field,
            de: newTranslatedStructuredText
        }
    }

    const updatedRecord = await client.items.update(recordId, updatedRecordContent);

    console.log(JSON.stringify(updatedRecord, null, 2))
})()


// Example types you can use if you want
type Localized<T> = {
    en?: T;
    de?: T;
    [locale: string]: T | undefined; // For other locales, or you can explicitly specify them like 'en' and 'de'
};

type ExampleBlockContents = SimpleSchemaTypes.ItemUpdateSchema & {
    paragraph_field_inside_block: string;
}

type ExampleRecord = SimpleSchemaTypes.ItemUpdateSchema & {
    title?: Localized<string>;
    modular_content_field?: Localized<SimpleSchemaTypes.ItemUpdateSchema[]>;
    structured_text_field?: Localized<StructuredTextDocument>;
}

Running it adds the German text:

Important notes:

  • To keep the existing locales, you have to first fetch the record’s current state
  • Then you use ES6 destructuring to pass along the existing data to the new record you’re building
  • You can use the nested: true param on a record to fetch all its block contents, instead of having to recursively look up its blocks individually
  • When constructing a new block, use the buildBlockRecord() helper function and do NOT provide an existing ID. You only use an existing ID to reference an existing block, not to build a new one.

Additional Details

This is what the CMA JS client is sending over the wire (click to expand):
{
  "data": {
    "id": "YtTI-MSMS2246DqXHet0jA",
    "type": "item",
    "attributes": {
      "title": {
        "de": "Beispieltext auf Deutsch",
        "en": "English title"
      },
      "modular_content_field": {
        "de": [
          {
            "type": "item",
            "attributes": {
              "paragraph_field_inside_block": "Beispiel-Block auf Deutsch"
            },
            "relationships": {
              "item_type": {
                "data": {
                  "id": "OI-LIDMeQ4a7KkgFO7DIUQ",
                  "type": "item_type"
                }
              }
            }
          }
        ],
        "en": [
          {
            "type": "item",
            "attributes": {
              "paragraph_field_inside_block": "English paragraph inside a modular content field"
            },
            "relationships": {
              "item_type": {
                "data": {
                  "id": "OI-LIDMeQ4a7KkgFO7DIUQ",
                  "type": "item_type"
                }
              }
            },
            "id": "cU57fLeASIaIRetgG96vpw"
          },
          {
            "type": "item",
            "attributes": {
              "paragraph_field_inside_block": "Another English paragraph inside a modular content field"
            },
            "relationships": {
              "item_type": {
                "data": {
                  "id": "OI-LIDMeQ4a7KkgFO7DIUQ",
                  "type": "item_type"
                }
              }
            },
            "id": "Hz1n0jbLS-ikG3yUL3XwbQ"
          }
        ]
      },
      "structured_text_field": {
        "de": {
          "schema": "dast",
          "document": {
            "type": "root",
            "children": [
              {
                "type": "paragraph",
                "children": [
                  {
                    "type": "span",
                    "value": "Deutsch in Strukturiertem Text"
                  }
                ]
              },
              {
                "type": "block",
                "item": {
                  "type": "item",
                  "attributes": {
                    "paragraph_field_inside_block": "Beispiel-Block auf Deutsch"
                  },
                  "relationships": {
                    "item_type": {
                      "data": {
                        "id": "OI-LIDMeQ4a7KkgFO7DIUQ",
                        "type": "item_type"
                      }
                    }
                  }
                }
              }
            ]
          }
        },
        "en": {
          "schema": "dast",
          "document": {
            "children": [
              {
                "children": [
                  {
                    "type": "span",
                    "value": "English structured text"
                  }
                ],
                "type": "paragraph"
              },
              {
                "item": {
                  "type": "item",
                  "attributes": {
                    "paragraph_field_inside_block": "English paragraph inside structured text"
                  },
                  "relationships": {
                    "item_type": {
                      "data": {
                        "id": "OI-LIDMeQ4a7KkgFO7DIUQ",
                        "type": "item_type"
                      }
                    }
                  },
                  "id": "a8RTQCc7RQGq3LI_TcITow"
                },
                "type": "block"
              },
              {
                "children": [
                  {
                    "type": "span",
                    "value": ""
                  }
                ],
                "type": "paragraph"
              }
            ],
            "type": "root"
          }
        }
      }
    },
    "relationships": {
      "item_type": {
        "data": {
          "id": "IdfCc2k-RCWrlGVfbwYpBA",
          "type": "item_type"
        }
      },
      "creator": {
        "data": {
          "id": "627975",
          "type": "organization"
        }
      }
    },
    "meta": {
      "created_at": "2025-03-25T17:54:16.208+00:00",
      "updated_at": "2025-03-25T19:15:35.522+00:00",
      "published_at": "2025-03-25T19:15:35.574+00:00",
      "publication_scheduled_at": null,
      "unpublishing_scheduled_at": null,
      "first_published_at": "2025-03-25T17:54:18.322+00:00",
      "is_valid": true,
      "is_current_version_valid": true,
      "is_published_version_valid": true,
      "status": "published",
      "current_version": "e98-Yf0LQrK6yD-JM2ihqw",
      "stage": null
    }
  }
}

Sorry, I know none of this is super-clear right now. I’ll update the docs and add a better example soon, but I hope this code helps a bit in the interim!


EDIT: In case anyone else comes across this later, a small clarification: You technically don’t need nested: true if you just want to preserve the existing English content. Without that param, existing blocks will be returned as an array of IDs, which you can pass verbatim back into an update call in order to preserve the existing content. That means passing an array of existing IDs is functionally equivalent to passing back an array of their nested contents (which would generate new blocks with new IDs, with the exact same content). However, because later in this thread the OP also asks about how to copy the English content over to other locales, keeping their structures intact but translating their strings, it is clearer to just keep the nested: true param intact so that the block contents, not just their IDs, can be used to mirror the content into other locales.

1 Like

Thank you for the detailed response, Roger!

The example you illustrate is really nice, but it presumes the block fields are relatively simple. Many of our blocks contain secondary Structured Text or Modular Content fields within, which complicates the CMA payload dramatically.

I can anticipate the issue by reading his snippet syntax.

In both the create case:

const newBlockContent: ExampleBlockContents = {
  item_type: {type: 'item_type', id: blockItemTypeId},
  paragraph_field_inside_block: 'Beispiel-Block auf Deutsch'
}

And the update case:

const updatedRecordContent: ExampleRecord = {
  ...currentRecord,
  title: {
    ...currentRecord.title, // Destructure & copy existing locales
    de: 'Beispieltext auf Deutsch' // Add new German string
  },
  modular_content_field: {
    ...currentRecord.modular_content_field, // Destructure & copy existing locales
    de: [newTranslatedBlock]
  },
  structured_text_field: {
    ...currentRecord.structured_text_field,
    de: newTranslatedStructuredText
  }
}

I cannot easily make these payload shapes by evaluating what comes back from the API for English content, and repeat it for each block in the collection.

To keep myself sane, I’m going to “Add locale” in the GUI (unless there’s a CMA facility to do this), and try to update blocks–creating new ones seems wildly error-prone.

In order to do that, I’ll need to perform a fairly sophisticated node traversal on given modular content collection field, trying to find translatable fields buried within Structured Text. The idea is to mark mutated blocks, recording the ID whenever I do so. Then, once I have the list, perform CMA requests to update blocks in order: deepest children first, all the way to root field.

Does that sound reasonable @roger?

Hey @drew,

What is the underlying use case here? Are you trying to copy the block schema & layout from English to each other locale 1:1, updating only the strings inside them? If so, yes, I think your approach makes sense… you can use the UI or the CMA (see below) to clone the content and then make another API call to update the content with translations.

I don’t know if this is clear, but you cannot directly update a block. Although technically blocks have IDs and look like normal records, they are not. The ID is an internal read-only identifier that should only be used when you want to reference an existing block for re-use. If you need to update the content in a block, you instead update the record that block is in and provide a new block of the same shape.

Confusing, isn’t it? Although a block looks like a related record (like a Link field, almost), it is different. Blocks don’t have their own lifecycle outside of a record, so you can’t update an individual block on its own, only copy its content into a new block. They are immutable “sub-records”, essentially.

So how would you actually use the CMA to do what you want? I think it would go something like this:

  • Fetch the existing record with English content, as above, with nested: true so you get the block contents in the same call.

  • Have a util function that traverses the English content tree, looking for any block IDs and stripping them (so the API doesn’t reject them, thinking that you’re trying to reuse an existing block). See example here from a plugin: removeBlockItemIds(). What you should be left with is a content tree of all your existing blocks, minus their block IDs (but keeping their block item type IDs).

  • Copy like content tree to every other locale (like de: {...currentRecord.currentField.en}) and then traverse into that field looking for appropriate fieldnames and replacing their contents.

  • Send that entire updated content tree back as ONE single items.update() call to provide all of the locales and blocks in one fell swoop, updating the whole record at once, including all the newly updated blocks. You do not need to (and indeed cannot) update each individual block.

    Any orphaned blocks from before the update (i.e. the original ones copied over from English, with the problematic IDs) will simply be discarded and your record will only have the new blocks you created just now.

Does that make sense…?

Let me see if I can make you a working example of this using that same demo schema with a bit more nesting. Or if you can post or DM me the URL of an example record you’re working with, I can do it in an example record there?


PS Did you already check out our plugins marketplace to see if any of them can do what you want to do? Like:

1 Like

Roger – that makes sense. My greatest concern is reproducing deeply nested blocks during translation and avoiding a rejection from the API.

Let me know what you think of this plan. I’ve marked steps I’ve already done with :white_check_mark:

  • use CMA to pull down entire record structure with nested=true :white_check_mark:
  • perform a deep node traversal on each block field :white_check_mark:
  • strip block IDs when I find them
  • perform language translation on appropriate nested string values :white_check_mark:
  • push the entire content tree back up as one single items.update() invocation, specifying en and de keys for every localizable field
    • modular content fields contain a mirror tree of en with blockIds stripped

Thank you for the plugin references! I will look at those for more sample code. You’ve been amazing through this support issue, I appreciate the effort and candor @roger :pray:

Yes, I think that makes sense! Try it out in a sandbox and let me know if you run into any issues.

I would still be happy to make you an example if that would help? Let me know.

And/or if you turn on the network inspector in your browser and try to add a new locale in the UI and copy over all the contents, you can see the exact body it’s sending.

The CMS UI itself uses the CMA, so anything you can do it in the UI you can do programmatically… in theory :sweat_smile: It’s just not always super clear what it’s doing (even to me, if that makes you feel any better…)

But yes, I think you have the general idea down! Try it all out in a sandbox, and let me know if anything unexpected comes up or you would like a fuller example.

1 Like

@roger – It worked! Thank you for your help :pray:t3:

The only extra detail I had to accommodate is to avoid stripping block IDs from the relationships key because you want to leave those IDs alone.

Aside from your detailed responses in this thread, the “copy locale” plugin helped greatly as well.

Much obliged, thank you!

1 Like

Great, glad it worked, and thanks for checking back in!