Update a record
We strongly recommend reading the Introduction to Records guide first. The payload for updating a record follows the same structure as creating one, so that guide is also an essential prerequisite.
The fundamental rules for structuring field values (i.e., strings, numbers, objects, references) are the same for both creating and updating records. For a complete reference on how to format the value for every field type, please see the Field Types Overview in the main records guide.
When updating an existing record, you only need to provide the fields you want to change. Any fields you omit from your payload will remain untouched.
There's a crucial difference between omitting a field and explicitly setting it to null or an empty value:
- Omitted fields keep their existing values unchanged
- Fields set to
nullor empty values (like[]for arrays) are cleared/deleted
import { buildClient, inspectItem } from "@datocms/cma-client-node";import type * as Schema from "./schema.js";
/* * BlogPost * ├─ title: string * ├─ description: string * ├─ featured_image: file * └─ content_blocks: modular_content * └─ HeroBlock: headline */
async function run() { // Make sure the API token has access to the CMA, and is stored securely const client = buildClient({ apiToken: process.env.DATOCMS_API_TOKEN });
const record = await client.items.find<Schema.BlogPost>( "T4m4tPymSACFzsqbZS65WA", { nested: true, }, );
console.log("-- BEFORE UPDATE --"); console.log(inspectItem(record));
const item = await client.items.update<Schema.BlogPost>( "T4m4tPymSACFzsqbZS65WA", { title: "[EDIT] My first blog post!", featured_image: null, content_blocks: [], }, );
console.log("-- AFTER UPDATE --"); console.log(inspectItem(item));}
run();-- BEFORE UPDATE --└ Item "T4m4tPymSACFzsqbZS65WA" (item_type: "UZyfjdBES8y2W2ruMEHSoA") ├ title: "My first blog post!" ├ description: "An introduction to our new blog platform" ├ featured_image: null └ content_blocks └ [0] Item "WtzyjA4sTLiLiOQ9TBNgtQ" (item_type: "DB5xsyzCQ3iHTx0dZPb3sw") └ headline: "Hello!"
-- AFTER UPDATE --└ Item "T4m4tPymSACFzsqbZS65WA" (item_type: "UZyfjdBES8y2W2ruMEHSoA") ├ title: "[EDIT] My first blog post!" ├ description: "An introduction to our new blog platform" ├ featured_image: null └ content_blocks: []The following sections highlight the rules and strategies that are specific to the update process.
TypeScript typing
Writing an update payload without typed schemas means writing blind: every field is unknown, typos compile fine, and mistakes only surface as 422s from the API. The single biggest lever you have is passing a generated Schema.X marker as the generic on items.update. TypeScript then enforces the model's field names, types, and allowed block shapes at compile time:
// ❌ Untyped: every field is `unknown`, typos compile.await client.items.update("record-id", { /* … */ });
// ✅ Typed: field names, types, and block shapes enforced.await client.items.update<Schema.Article>("record-id", { /* … */ });When you're rebuilding a field value piece-by-piece — typically an array of blocks or links — annotate the accumulator with FieldValueInRequest<T, 'field_key'>. The first argument accepts any item-shaped value the CMA produces (top-level record or nested block, narrowed or not) or a Schema.X marker directly when no value is in scope yet:
const article = await client.items.find<Schema.Article>("record-id", { nested: true });
if (article.content) { const content: NonNullable<FieldValueInRequest<typeof article, "content">> = /* ...transform article.content... */; await client.items.update<Schema.Article>("record-id", { content });}
// Same expression on a narrowed nested block:for (const block of article.sections) { if (isBlockOfType(Schema.HeroBlock.ID, block)) { const ctas: NonNullable<FieldValueInRequest<typeof block, "ctas">> = []; // …rebuild the nested field… }}
// Or, if you don't have a value yet, pass the marker:type ArticleContent = NonNullable<FieldValueInRequest<Schema.Article, "content">>;Even fields with a required validator are typed as Nullable, because the API may still accept/return null in some scenarios.
The example therefore checks if (article.content) and uses NonNullable<…> to narrow the type. Without this, accessing nested properties like content.document.children would require optional chaining (?.).
Updating Block Fields
The general workflow is the same for every block field type:
- Generate types with the TypeScript schema generator.
- Fetch records with
nested: trueso blocks come back as full objects you can edit. Without it you only get IDs — fine for keep/reorder/delete, not for editing. - Build the payload following the per-field-type rules below. Lean on the provided helpers as much as possible instead of assembling structures by hand.
- Send a single
client.items.update<Article>call, omitting fields you don't change.
Modular Content
Payload is an array of blocks. Each entry expresses one operation:
| Operation | Entry |
|---|---|
| Keep | block ID string |
| Edit | buildBlockRecord<T>({ id, ...changedAttrs }) |
| Create | buildBlockRecord<T>({ ...allAttrs }) |
| Clone | duplicateBlockRecord<T>(block, schemaRepository) |
| Delete | omit from the array |
| Reorder | rearrange the array |
Demonstrates every Modular Content operation in a single items.update call — keep, edit, create, clone, delete, and reorder all happen at once — and shows that the same accumulator pattern composes recursively when a block holds its own modular field:
- Create — top of the array:
buildBlockRecord<CallToActionBlock>({ item_type, ...attrs })(noid, since it's brand new). - Clone — duplicate the first existing testimonial with
duplicateBlockRecord<TestimonialBlock>(block, schemaRepository). The helper deep-copies and strips block IDs, so the result becomes a separate block rather than the same one moved. - Delete —
.filter(...)drops the trailing "Start Your Free Trial" CTA. Any block missing from the new array is removed; missing IDs are how the API encodes deletion. - Edit (flat) —
buildBlockRecord<CallToActionBlock>({ id, button_url })with only the changed attributes; omitted attributes (button_text) stay as-is on the server. - Edit (nested) — when the iterated block is a
HeroBlock, rebuild itsctasmodular array with the same accumulator pattern:NonNullable<FieldValueInRequest<typeof block, 'ctas'>>types the inner array, and the inner loop reusesbuildBlockRecord/ bare-ID-string the same way the outer loop does. The pattern composes uniformly to whatever depth the schema reaches. - Keep — return the bare ID string. Cheapest possible payload entry, used both at the top level and inside the nested
ctas. - Reorder — implicit; the new array's order is the final order.
isBlockOfType(ID) is the curried predicate for Array#filter / Array#find and the inline guard inside .map callbacks — narrows block.attributes and block.__itemTypeId to the matching block model.
import { buildBlockRecord, buildClient, duplicateBlockRecord, type FieldValueInRequest, inspectItem, isBlockOfType, SchemaRepository,} from "@datocms/cma-client-node";import * as Schema from "./schema.js";
/* * LandingPage * ├─ title: string * └─ sections: modular_content * ├─ HeroBlock: headline, subtitle * │ └─ ctas: modular_content * │ └─ ButtonBlock: label, url * ├─ CallToActionBlock: button_text, button_url * └─ TestimonialBlock: quote, author */
async function run() { // Make sure the API token has access to the CMA, and is stored securely const client = buildClient({ apiToken: process.env.DATOCMS_API_TOKEN });
const schemaRepository = new SchemaRepository(client);
const currentPage = await client.items.find<Schema.LandingPage>( "W4wcrs_2REiM4fc6dlDZCQ", { nested: true }, );
console.log("-- BEFORE UPDATE --"); console.log(inspectItem(currentPage));
const firstTestimonial = currentPage.sections.find( isBlockOfType(Schema.TestimonialBlock.ID), ); const duplicatedTestimonial = firstTestimonial ? await duplicateBlockRecord<Schema.TestimonialBlock>( firstTestimonial, schemaRepository, ) : null;
const sections: NonNullable< FieldValueInRequest<typeof currentPage, "sections"> > = [ // Create — fresh CallToActionBlock at the top buildBlockRecord<Schema.CallToActionBlock>({ item_type: Schema.CallToActionBlock.REF, button_text: "Watch a 2-minute demo", button_url: "https://www.datocms.com/demo", }),
// Clone — duplicated testimonial ...(duplicatedTestimonial ? [duplicatedTestimonial] : []),
...currentPage.sections // Delete — drop the trailing CTA by filtering it out .filter( (block) => !( isBlockOfType(Schema.CallToActionBlock.ID, block) && block.attributes.button_text === "Start Your Free Trial" ), ) .map((block) => { if (isBlockOfType(Schema.HeroBlock.ID, block)) { // Edit (nested) — rebuild the HeroBlock's `ctas` using the SAME // accumulator pattern, applied recursively to a nested block's // modular field. const ctas: NonNullable<FieldValueInRequest<typeof block, "ctas">> = block.attributes.ctas.map((cta) => { if ( isBlockOfType(Schema.ButtonBlock.ID, cta) && cta.attributes.label === "Get started" ) { const url = new URL( cta.attributes.url || "https://www.datocms.com", ); url.searchParams.set("utm_source", "hero"); return buildBlockRecord<Schema.ButtonBlock>({ id: cta.id, url: url.toString(), }); } return cta.id; });
return buildBlockRecord<Schema.HeroBlock>({ id: block.id, ctas }); }
if (isBlockOfType(Schema.CallToActionBlock.ID, block)) { // Edit (flat) const url = new URL( block.attributes.button_url || "https://www.datocms.com", ); url.searchParams.set("utm_source", "landing_page"); url.searchParams.set("utm_medium", "cta"); url.searchParams.set("utm_campaign", "q1_2024");
return buildBlockRecord<Schema.CallToActionBlock>({ id: block.id, button_url: url.toString(), }); }
// Keep — return the bare ID for unchanged blocks return block.id; }), ];
console.log("-- UPDATE OPERATION --"); console.log(inspectItem({ sections }));
await client.items.update<Schema.LandingPage>(currentPage, { sections });
const updatedPage = await client.items.find<Schema.LandingPage>(currentPage, { nested: true, });
console.log("-- AFTER UPDATE --"); console.log(inspectItem(updatedPage));}
run();-- BEFORE UPDATE --└ Item "W4wcrs_2REiM4fc6dlDZCQ" (item_type: "ZV0o9497SsqWxQR8HEQddw") ├ title: "Product Launch Landing Page" └ sections ├ [0] Item "Ngzm6x8JS2y9UuQnTf9wBw" (item_type: "d-CHYg-rShOt3kiL6ZN1yA") │ ├ headline: "Revolutionary New Solution" │ ├ subtitle: "Discover the future of productivity with our cutting-edge platform designed f..." │ └ ctas │ ├ [0] Item "IfiDssuLSEa5HH2-Ce2nCw" (item_type: "TNTfjVnfQieREzX98q3xnw") │ │ ├ label: "Get started" │ │ └ url: "https://www.datocms.com/signup" │ └ [1] Item "IOggXsdxRAmFWCOPhbFXYw" (item_type: "TNTfjVnfQieREzX98q3xnw") │ ├ label: "Read the docs" │ └ url: "https://www.datocms.com/docs" ├ [1] Item "Iu1vi__ASDeDrrpPpKbknQ" (item_type: "I8Q6k-HqQmaZ498WKtvFbg") │ ├ button_text: "Get Started Free" │ └ button_url: "https://www.datocms.com/signup" ├ [2] Item "NxszxFtnSpmftjnxguWPOg" (item_type: "Dy9C52o4S6eF3mqSOmeUtg") │ ├ quote: "This platform completely transformed how our team collaborates. We've seen a ..." │ └ author: "Sarah Chen, Product Manager at TechCorp" ├ [3] Item "MIJbkpwMQiqzwOYQkuAVIw" (item_type: "Dy9C52o4S6eF3mqSOmeUtg") │ ├ quote: "The best investment we've made this year. The ROI was evident within the firs..." │ └ author: "Michael Rodriguez, CTO at InnovateLabs" └ [4] Item "CVcgWRiaRYKF53DvqZHpFA" (item_type: "I8Q6k-HqQmaZ498WKtvFbg") ├ button_text: "Start Your Free Trial" └ button_url: "https://www.datocms.com/trial"
-- UPDATE OPERATION --└ Item └ sections ├ [0] Item (item_type: "I8Q6k-HqQmaZ498WKtvFbg") │ ├ button_text: "Watch a 2-minute demo" │ └ button_url: "https://www.datocms.com/demo" ├ [1] Item (item_type: "Dy9C52o4S6eF3mqSOmeUtg") │ ├ quote: "This platform completely transformed how our team collaborates. We've seen a ..." │ └ author: "Sarah Chen, Product Manager at TechCorp" ├ [2] Item "Ngzm6x8JS2y9UuQnTf9wBw" │ └ ctas │ ├ [0] Item "IfiDssuLSEa5HH2-Ce2nCw" │ │ └ url: "https://www.datocms.com/signup?utm_source=hero" │ └ [1] "IOggXsdxRAmFWCOPhbFXYw" ├ [3] Item "Iu1vi__ASDeDrrpPpKbknQ" │ └ button_url: "https://www.datocms.com/signup?utm_source=landing_page&utm_medium=cta&utm_cam..." ├ [4] "NxszxFtnSpmftjnxguWPOg" └ [5] "MIJbkpwMQiqzwOYQkuAVIw"
-- AFTER UPDATE --└ Item "W4wcrs_2REiM4fc6dlDZCQ" (item_type: "ZV0o9497SsqWxQR8HEQddw") ├ title: "Product Launch Landing Page" └ sections ├ [0] Item "O43QkMpUQFy4mTck48Ayeg" (item_type: "I8Q6k-HqQmaZ498WKtvFbg") │ ├ button_text: "Watch a 2-minute demo" │ └ button_url: "https://www.datocms.com/demo" ├ [1] Item "JQOSGafZTgK1cDWaFgtK9Q" (item_type: "Dy9C52o4S6eF3mqSOmeUtg") │ ├ quote: "This platform completely transformed how our team collaborates. We've seen a ..." │ └ author: "Sarah Chen, Product Manager at TechCorp" ├ [2] Item "Ngzm6x8JS2y9UuQnTf9wBw" (item_type: "d-CHYg-rShOt3kiL6ZN1yA") │ ├ headline: "Revolutionary New Solution" │ ├ subtitle: "Discover the future of productivity with our cutting-edge platform designed f..." │ └ ctas │ ├ [0] Item "IfiDssuLSEa5HH2-Ce2nCw" (item_type: "TNTfjVnfQieREzX98q3xnw") │ │ ├ label: "Get started" │ │ └ url: "https://www.datocms.com/signup?utm_source=hero" │ └ [1] Item "IOggXsdxRAmFWCOPhbFXYw" (item_type: "TNTfjVnfQieREzX98q3xnw") │ ├ label: "Read the docs" │ └ url: "https://www.datocms.com/docs" ├ [3] Item "Iu1vi__ASDeDrrpPpKbknQ" (item_type: "I8Q6k-HqQmaZ498WKtvFbg") │ ├ button_text: "Get Started Free" │ └ button_url: "https://www.datocms.com/signup?utm_source=landing_page&utm_medium=cta&utm_cam..." ├ [4] Item "NxszxFtnSpmftjnxguWPOg" (item_type: "Dy9C52o4S6eF3mqSOmeUtg") │ ├ quote: "This platform completely transformed how our team collaborates. We've seen a ..." │ └ author: "Sarah Chen, Product Manager at TechCorp" └ [5] Item "MIJbkpwMQiqzwOYQkuAVIw" (item_type: "Dy9C52o4S6eF3mqSOmeUtg") ├ quote: "The best investment we've made this year. The ROI was evident within the firs..." └ author: "Michael Rodriguez, CTO at InnovateLabs"Single Block
A single-block field holds at most one block (or null), so the payload is the value itself — not an array. The Modular Content rules apply, minus anything position-related.
Demonstrates every Single Block operation across multiple items.update calls — the field holds at most one block (or null):
- Edit —
buildBlockRecord<CallToActionBlock>({ id, button_text, style })keeps the same block ID, changes the listed attributes, leaves the rest (button_url) untouched. - Replace via duplicate —
duplicateBlockRecord<HeroBlock | CallToActionBlock | VideoBlock>(currentProduct.hero_section, schemaRepository)deep-copies and strips IDs; the slot ends up with a brand-new block, not the same one re-used. - Replace with a different block type —
buildBlockRecord<VideoBlock>({ item_type, ...attrs })swaps the slot's model entirely (noid, since the block doesn't exist yet). - Delete — set the field to
null. Omitting the field would leave the existing block in place (see the parent guide's null-vs-omit warning).
The response uses __itemTypeId as a discriminant: narrowing on it (if (currentProduct.hero_section.__itemTypeId === CTA_ID)) lets TypeScript see the matching block-model attributes before any string ops.
import { type ApiTypes, buildBlockRecord, buildClient, duplicateBlockRecord, inspectItem, SchemaRepository,} from "@datocms/cma-client-node";import * as Schema from "./schema.js";
/* * ProductPage * ├─ title: string * ├─ price: float * └─ hero_section: single_block * ├─ HeroBlock: headline, description, background_image * ├─ CallToActionBlock: button_text, button_url, style * └─ VideoBlock: video_url, thumbnail_image, autoplay */
// Make sure the API token has access to the CMA, and is stored securelyconst client = buildClient({ apiToken: process.env.DATOCMS_API_TOKEN });
async function run() { const schemaRepository = new SchemaRepository(client);
const currentProduct = await client.items.find<Schema.ProductPage>( "W4wcrs_2REiM4fc6dlDZCQ", { nested: true }, );
console.log("-- BEFORE UPDATE --"); console.log(inspectItem(currentProduct));
if ( currentProduct.hero_section && currentProduct.hero_section.__itemTypeId === Schema.CallToActionBlock.ID ) { await client.items.update<Schema.ProductPage>(currentProduct, { hero_section: buildBlockRecord<Schema.CallToActionBlock>({ id: currentProduct.hero_section.id, button_text: currentProduct.hero_section.attributes.button_text?.toUpperCase() || "SHOP NOW", style: "primary-large", }), }); console.log("-- EXISTING BLOCK UPDATED --"); await inspectItemWithNestedBlocks(currentProduct); }
await client.items.update<Schema.ProductPage>(currentProduct, { hero_section: await duplicateBlockRecord< Schema.HeroBlock | Schema.CallToActionBlock | Schema.VideoBlock >(currentProduct.hero_section!, schemaRepository), }); console.log("-- BLOCK DUPLICATE --"); await inspectItemWithNestedBlocks(currentProduct);
const upload = await client.uploads.createFromUrl({ url: "https://picsum.photos/800/600?random=1", }); const productWithVideo = await client.items.update<Schema.ProductPage>( currentProduct, { hero_section: buildBlockRecord<Schema.VideoBlock>({ item_type: Schema.VideoBlock.REF, video_url: "https://videos.datocms.com/product-demo.mp4", thumbnail_image: { upload_id: upload.id }, autoplay: false, }), }, ); console.log("-- BLOCK REPLACED --"); await inspectItemWithNestedBlocks(currentProduct);
await client.items.update<Schema.ProductPage>(productWithVideo, { hero_section: null, }); console.log("-- BLOCK REMOVED --"); await inspectItemWithNestedBlocks(currentProduct);}
run();
async function inspectItemWithNestedBlocks(item: ApiTypes.Item) { const itemWithNestedBlocks = await client.items.find(item, { nested: true, }); console.log(inspectItem(itemWithNestedBlocks));}-- BEFORE UPDATE --└ Item "W4wcrs_2REiM4fc6dlDZCQ" (item_type: "ZV0o9497SsqWxQR8HEQddw") ├ title: "Premium Wireless Headphones" ├ price: 299.99 └ hero_section └ Item "MolF0AwpSLeXrdE5kdcEtw" (item_type: "I8Q6k-HqQmaZ498WKtvFbg") ├ button_text: "Buy Now" ├ button_url: "https://example.com/buy" └ style: "primary"
-- EXISTING BLOCK UPDATED --└ Item "W4wcrs_2REiM4fc6dlDZCQ" (item_type: "ZV0o9497SsqWxQR8HEQddw") ├ title: "Premium Wireless Headphones" ├ price: 299.99 └ hero_section └ Item "MolF0AwpSLeXrdE5kdcEtw" (item_type: "I8Q6k-HqQmaZ498WKtvFbg") ├ button_text: "BUY NOW" ├ button_url: "https://example.com/buy" └ style: "primary-large"
-- BLOCK DUPLICATE --└ Item "W4wcrs_2REiM4fc6dlDZCQ" (item_type: "ZV0o9497SsqWxQR8HEQddw") ├ title: "Premium Wireless Headphones" ├ price: 299.99 └ hero_section └ Item "QXqFgPHVTfq8F1tOmQEwEg" (item_type: "I8Q6k-HqQmaZ498WKtvFbg") ├ button_text: "Buy Now" ├ button_url: "https://example.com/buy" └ style: "primary"
-- BLOCK REPLACED --└ Item "W4wcrs_2REiM4fc6dlDZCQ" (item_type: "ZV0o9497SsqWxQR8HEQddw") ├ title: "Premium Wireless Headphones" ├ price: 299.99 └ hero_section └ Item "JIHRl3kyQiGXAJHcP-7v7Q" (item_type: "Dy9C52o4S6eF3mqSOmeUtg") ├ video_url: "https://videos.datocms.com/product-demo.mp4" ├ thumbnail_image │ └ upload_id: "WwqHexgISQqQdMKJSYE8VA" └ autoplay: false
-- BLOCK REMOVED --└ Item "W4wcrs_2REiM4fc6dlDZCQ" (item_type: "ZV0o9497SsqWxQR8HEQddw") ├ title: "Premium Wireless Headphones" ├ price: 299.99 └ hero_section: nullStructured Text
Structured Text stores rich content as a DAST tree. Embedded records show up as two extra node kinds: block and inlineBlock. Both wrap a full DatoCMS block, so the same buildBlockRecord/duplicateBlockRecord primitives from Modular Content and Single Block still apply, but they're composed inside a tree-mutation pipeline rather than dropped into an array slot.
The pipeline has two passes, each exposing the document at a different level of granularity. Pick the highest-level pass that can still see what you want to change. Going lower buys you more power but costs you a lot of tree gymnastics.
Pass 1: Rewrite the prose (dastdown)
datocms-structured-text-dastdown translates the DAST tree to and from a markdown-like text format:
A normal paragraph with ==highlighted==, ++underlined++ and ~~struck~~ text.
Links can carry trailers: [our docs](https:datocms.com){target="_blank"}.References to other records read like [this article](dato:item/abc123),or inline as <inlineItem id="abc123"/>.
> A pull quote from the interview.> {attribution="Jane Doe"}
<block id="def456"/>
Inline blocks <inlineBlock id="ghi789"/> sit mid-sentence.This allows you to make changes with ordinary string operations and let parse() rebuild the tree:
import { parse, serialize } from "datocms-structured-text-dastdown";
const text = serialize(currentRecord.content);
const edited = text .replace(/Jane Doe/g, "Jane Smith") .replace(/==([^=]+)==/g, "**$1**");
const content = parse(edited, currentRecord.content);Blocks appear in dastdown only as ID placeholders: you can move or delete them, but you can't touch what's inside.
If you can describe the change as something you'd do in a text editor — find-and-replace, rewriting a paragraph, reshaping a list — dastdown is the right tool. It also wins on the opposite end of the spectrum from Pass 2: one-off spot edits, and agentic flows where an LLM reads the serialized text and rewrites it directly.
Pass 2: Mutate the tree (mapNodes + block helpers)
Reach for Pass 2 when dastdown can't see what you want to change — anything that depends on node kind rather than text, or on a block's internal fields rather than its position. Both flavors of edit live inside the same mapNodes walk:
- Transforming prose nodes. Rewrite every link's URL, lowercase every heading, bump every
level: 2heading tolevel: 3, drop every empty paragraph, wrap every occurrence of "click here" in a link. dastdown is the right tool while edits stay mostly text-shaped; once you find yourself writing regex to fish node kinds back out of the serialized form, the AST is the cleaner layer. - Editing or building embedded blocks. Edit a block's attributes, swap it for another, or drop a brand-new block into the tree. Embedded blocks are opaque to Pass 1 (dastdown serializes them to
<block id="…"/>placeholders that hide their fields), so anything touching block contents lands here. UsebuildBlockRecord<T>to shape the payload — pass anidto edit an existing block, omit it to create a new one — andduplicateBlockRecord<T>to deep-clone one.
mapNodes from datocms-structured-text-utils walks the tree bottom-up (a node's descendants have already been transformed by the time the callback sees it, and what you return for that node is final). Return one node (1:1, the default), an array splatted into siblings (1:N — split, wrap, insert), or null/undefined to drop (1:0 — illegal at the root).
When a node doesn't need to change, just return node. The CMA accepts the nested-response shape it came in as.
Adding root-level nodes (a brand-new paragraph, a fresh top-level block) sits just outside the callback: mapNodes can't splat at the root, so push directly into content.document.children after the walk — using buildBlockRecord / duplicateBlockRecord to shape any new block entry.
Pass 1's parse() uses the original document as the lookup table for <block id="…"/> placeholders. A block created by Pass 2 first would either be missing from that lookup (and parse throws) or get silently overwritten when Pass 1 rehydrates. If you need both, always run Pass 1 before Pass 2.
Demonstrates Pass 1 (dastdown round-trip) on a structured-text field with no embedded blocks. Three text-level transformations expressed as plain string operations:
- Brand swap —
text.replace(/ZEIT/g, "Vercel")rewrites every occurrence across spans, headings, and link text in a single regex. - Autolink emails — turn bare addresses into markdown links:
support@example.com→[support@example.com](mailto:support@example.com). - Set
target="_blank"on a specific link — append the dastdown link-meta trailer{target="_blank"}to the matching(url). The negative lookahead(?!\{)makes the regex idempotent (a second run is a no-op).
Block placeholders aren't relevant here, but parse(text, currentGuide.body) always uses the second argument as the <block id="…"/> lookup table; missing IDs throw, which is the signal to fall back to Pass 2.
import { buildClient, inspectItem } from "@datocms/cma-client-node";import { parse, serialize } from "datocms-structured-text-dastdown";import type * as Schema from "./schema.js";
/* * Guide * ├─ title: string * └─ body: structured_text (no embedded blocks for this example) */
async function run() { // Make sure the API token has access to the CMA, and is stored securely const client = buildClient({ apiToken: process.env.DATOCMS_API_TOKEN });
const currentGuide = await client.items.find<Schema.Guide>( "Q9zHRrIESkGYBV3hVVe2Hg", );
// `currentGuide.body` is typed nullable — guard before touching the document. // Wrapping in `if (currentGuide.body) { ... }` also avoids a top-level // `return`, letting peek + mutate live in a single script. if (currentGuide.body) { console.log("-- BEFORE UPDATE --"); console.log(inspectItem(currentGuide));
const text = serialize(currentGuide.body);
const edited = text .replace(/ZEIT/g, "Vercel") .replace(/(\b[\w.+-]+@[\w-]+\.[\w.-]+\b)/g, "[$1](mailto:$1)") .replace( /\(https:\/\/example\.com\/migration\)(?!\{)/g, '(https://example.com/migration){target="_blank"}', );
// 2nd arg is the lookup table for `<block id="…"/>` placeholders; missing IDs throw. const body = parse(edited, currentGuide.body);
await client.items.update<Schema.Guide>(currentGuide.id, { body });
console.log("-- AFTER UPDATE --"); console.log( inspectItem(await client.items.find<Schema.Guide>(currentGuide.id)), ); }}
run();-- BEFORE UPDATE --└ Item "Q9zHRrIESkGYBV3hVVe2Hg" (item_type: "f6sjkkPgQGiSDi6lwG3UjA") ├ title: "Deploying with ZEIT" └ body ├ heading (level: 1) │ └ span "Deploying with ZEIT" ├ paragraph │ └ span "ZEIT lets you ship static sites and serverless functions in seconds. ..." └ paragraph ├ span "Read our " ├ link (url: "https://example.com/migration") │ └ span "migration guide" └ span " before upgrading from ZEIT v1."
-- AFTER UPDATE --└ Item "Q9zHRrIESkGYBV3hVVe2Hg" (item_type: "f6sjkkPgQGiSDi6lwG3UjA") ├ title: "Deploying with ZEIT" └ body ├ heading (level: 1) │ └ span "Deploying with Vercel" ├ paragraph │ ├ span "Vercel lets you ship static sites and serverless functions in seconds..." │ ├ link (url: "mailto:support@zeit.co") │ │ └ span "support@zeit.co" │ └ span " for help." └ paragraph ├ span "Read our " ├ link (url: "https://example.com/migration", meta: {target="_blank"}) │ └ span "migration guide" └ span " before upgrading from Vercel v1."Demonstrates Pass 2 on prose nodes — mapNodes with one of each return mode (1:1 transforms, 1:0 drop) plus a post-walk root-level append. The callback applies five edits across the tree:
- Demote
h1→h2for hierarchy hygiene (as constkeeps the literalleveltype). - Bold every span that mentions the brand, deduping the
marksarray with aSet(canonical marks:'strong' | 'emphasis' | 'code' | 'underline' | 'strikethrough' | 'highlight'). - Brand swap —
value.replace(/ZEIT/g, "Vercel")on every matching span. - Add
target="_blank"tolinkanditemLinknodes, deduping any priortargetentry inmeta. - Drop empty paragraphs by returning
nullfrom the callback.
After mapNodes returns, a fresh paragraph is pushed into content.document.children — root-level inserts can't go through the callback (splat-at-root throws).
import { buildClient, inspectItem } from "@datocms/cma-client-node";import { isHeading, isItemLink, isLink, isParagraph, isSpan, mapNodes, reduceNodes,} from "datocms-structured-text-utils";import type * as Schema from "./schema.js";
/* * BlogPost * ├─ title: string * ├─ slug: string * └─ content: structured_text (no embedded blocks for this example) */
async function run() { // Make sure the API token has access to the CMA, and is stored securely const client = buildClient({ apiToken: process.env.DATOCMS_API_TOKEN });
const currentPost = await client.items.find<Schema.BlogPost>( "T4m4tPymSACFzsqbZS65WA", );
// Guard the nullable field — also keeps peek + mutate in a single script // without a top-level `return`. if (currentPost.content) { console.log("-- BEFORE UPDATE --"); console.log(inspectItem(currentPost));
const content = mapNodes(currentPost.content, (node) => { if (isHeading(node) && node.level === 1) { // `as const` preserves the literal `2` instead of widening to `number`. return { ...node, level: 2 as const }; }
if (isSpan(node) && node.value.includes("ZEIT")) { const marks = new Set(node.marks ?? []); marks.add("strong"); return { ...node, value: node.value.replace(/ZEIT/g, "Vercel"), marks: [...marks], }; }
if (isLink(node) || isItemLink(node)) { const meta = [ ...(node.meta ?? []).filter((m) => m.id !== "target"), { id: "target", value: "_blank" }, ]; return { ...node, meta }; }
if ( isParagraph(node) && reduceNodes( node, (acc, n) => (isSpan(n) ? acc + n.value.trim() : acc), "", ).length === 0 ) { return null; }
return node; });
// Root-level inserts can't go through `mapNodes` (splat-at-root throws); push directly. content.document.children.push({ type: "paragraph", children: [{ type: "span", value: "Last updated by the content team." }], });
await client.items.update<Schema.BlogPost>(currentPost.id, { content });
console.log("-- AFTER UPDATE --"); console.log( inspectItem(await client.items.find<Schema.BlogPost>(currentPost.id)), ); }}
run();-- BEFORE UPDATE --└ Item "T4m4tPymSACFzsqbZS65WA" (item_type: "UZyfjdBES8y2W2ruMEHSoA") ├ title: "What is ZEIT?" ├ slug: "what-is-zeit" └ content ├ heading (level: 1) │ └ span "Understanding ZEIT" ├ paragraph │ └ span "ZEIT is a cloud platform for static sites and serverless functions. I..." ├ paragraph │ └ span "" ├ paragraph │ └ span "" ├ heading (level: 1) │ └ span "Key Features" ├ paragraph │ ├ span "ZEIT offers automatic HTTPS, global CDN distribution, and instant dep..." │ ├ link (url: "https://example.com/blog") │ │ └ span "detailed comparison" │ └ span " for more insights." └ paragraph ├ span "Visit our " ├ itemLink (item: "fpgJWZadRI66eqXB-ucSSQ") │ └ span "migration guide" └ span " for step-by-step instructions."
-- AFTER UPDATE --└ Item "T4m4tPymSACFzsqbZS65WA" (item_type: "UZyfjdBES8y2W2ruMEHSoA") ├ title: "What is ZEIT?" ├ slug: "what-is-zeit" └ content ├ heading (level: 2) │ └ span (marks: strong) "Understanding Vercel" ├ paragraph │ └ span (marks: strong) "Vercel is a cloud platform for static sites and serverless functions...." ├ heading (level: 2) │ └ span "Key Features" ├ paragraph │ ├ span (marks: strong) "Vercel offers automatic HTTPS, global CDN distribution, and instant d..." │ ├ link (url: "https://example.com/blog", meta: {target="_blank"}) │ │ └ span "detailed comparison" │ └ span " for more insights." ├ paragraph │ ├ span "Visit our " │ ├ itemLink (item: "fpgJWZadRI66eqXB-ucSSQ", meta: {target="_blank"}) │ │ └ span "migration guide" │ └ span " for step-by-step instructions." └ paragraph └ span "Last updated by the content team."Demonstrates Pass 2 on embedded blocks — editing block attributes from inside a mapNodes callback, then duplicating and appending a block after the walk. What the script does:
- Edit CTA blocks: rewrite
button_urlfromold-domain.comtonew-domain.com, returning{ ...node, item: buildBlockRecord<CtaBlock>({ id, button_url }) }from themapNodescallback. - Tag inline product mentions: append
?source=article_mentiontoaffiliate_urlwith the same pattern, narrowing viaisInlineBlockWithItemOfType. - Pass through unmatched blocks (the
ImageGalleryBlockhere) with a barereturn node— the CMA accepts the nested-response shape it came in as. - Duplicate the first CTA with
duplicateBlockRecord<CtaBlock>and push it as a new root-level child, aftermapNodesruns.
A few things to notice:
isBlockWithItemOfTypehas two call styles.isBlockWithItemOfType(ID, node)is the inline form forif; the curried formisBlockWithItemOfType(ID)is the predicate forfindFirstNode/Array#find/Array#filter. Same guard, different ergonomics. WithIDdeclaredas const, both auto-narrownode.itemto the matching block-model shape.- Source the duplicate from the original tree. Look up the source block on
currentArticle.content(the original response), not on the mapped result —mapNodesis allowed to rewritenode.item, so the post-map tree is not a reliable source for cloning.
import { buildBlockRecord, buildClient, duplicateBlockRecord, type FieldValueInRequest, inspectItem, SchemaRepository,} from "@datocms/cma-client-node";import { findFirstNode, isBlockWithItemOfType, isInlineBlockWithItemOfType, mapNodes,} from "datocms-structured-text-utils";import * as Schema from "./schema.js";
/* * Article * ├─ title: string * ├─ author: string * └─ content: structured_text * ├─ CtaBlock: title, description, button_text, button_url * ├─ ProductMentionInline: product_name, price, affiliate_url * └─ ImageGalleryBlock: title, images */
async function run() { // Make sure the API token has access to the CMA, and is stored securely const client = buildClient({ apiToken: process.env.DATOCMS_API_TOKEN });
// `duplicateBlockRecord` looks up nested-block field definitions through this. const repo = new SchemaRepository(client);
const currentArticle = await client.items.find<Schema.Article>( "RSfdsZbbR7ixGgMBSmcaVA", { nested: true, }, );
// Guard the nullable field — also keeps peek + mutate in a single script // without a top-level `return`. if (currentArticle.content) { console.log("-- BEFORE UPDATE --"); console.log(inspectItem(currentArticle));
let content: NonNullable< FieldValueInRequest<typeof currentArticle, "content"> > = currentArticle.content;
content = mapNodes(content, (node) => { if (isBlockWithItemOfType(Schema.CtaBlock.ID, node)) { const url = node.item.attributes.button_url; if (url?.includes("old-domain.com")) { return { ...node, item: buildBlockRecord<Schema.CtaBlock>({ id: node.item.id, button_url: url.replace("old-domain.com", "new-domain.com"), }), }; } }
if (isInlineBlockWithItemOfType(Schema.ProductMentionInline.ID, node)) { const raw = node.item.attributes.affiliate_url; if (raw) { const url = new URL(raw); url.searchParams.set("source", "article_mention"); return { ...node, item: buildBlockRecord<Schema.ProductMentionInline>({ id: node.item.id, affiliate_url: url.toString(), }), }; } }
return node; });
// Source the duplicate from the original tree: `mapNodes` may have // rewritten `node.item`, so post-map `content` is not safe to clone from. const firstCta = findFirstNode( currentArticle.content, isBlockWithItemOfType(Schema.CtaBlock.ID), );
if (firstCta) { const dup = await duplicateBlockRecord<Schema.CtaBlock>( firstCta.node.item, repo, ); content.document.children.push({ type: "block", item: dup }); }
await client.items.update<Schema.Article>(currentArticle.id, { content });
console.log("-- AFTER UPDATE --"); console.log( inspectItem( await client.items.find<Schema.Article>(currentArticle.id, { nested: true, }), ), ); }}
run();-- BEFORE UPDATE --└ Item "RSfdsZbbR7ixGgMBSmcaVA" (item_type: "ZV0o9497SsqWxQR8HEQddw") ├ title: "The Future of E-commerce Technology" ├ author: "Alex Thompson" └ content ├ paragraph │ ├ span "E-commerce is evolving rapidly with new technologies like " │ ├ inlineBlock │ │ └ Item "LQlcO4LCTYaOrfj2A705DQ" (item_type: "VGXgXav9SwG5P48frGrFxA") │ │ ├ product_name: "AI Shopping Assistant" │ │ ├ price: 99.99 │ │ └ affiliate_url: "https://old-domain.com/product?ref=blog" │ └ span " transforming how customers shop online." ├ block │ └ Item "FqTxaDO8TJmtkoTgDjbK8Q" (item_type: "d-CHYg-rShOt3kiL6ZN1yA") │ ├ title: "Join the Revolution" │ ├ description: "Stay ahead of the curve with our e-commerce insights." │ ├ button_text: "Subscribe Now" │ └ button_url: "https://old-domain.com/subscribe" └ block └ Item "JwT0hvgiR4WB0-SO9QG7Ig" (item_type: "I8Q6k-HqQmaZ498WKtvFbg") ├ title: "E-commerce Innovation Gallery" └ images ├ [0] │ ├ upload_id: "eLVHtrefRUq7qkVMpzG6mQ" │ ├ alt: "Modern e-commerce interface" │ └ title: "Next-gen Shopping" └ [1] ├ upload_id: "UFIyIRQFT1GQ7YAG34ub5w" ├ alt: "AI-powered recommendations" └ title: "Smart Product Discovery"
-- AFTER UPDATE --└ Item "RSfdsZbbR7ixGgMBSmcaVA" (item_type: "ZV0o9497SsqWxQR8HEQddw") ├ title: "The Future of E-commerce Technology" ├ author: "Alex Thompson" └ content ├ paragraph │ ├ span "E-commerce is evolving rapidly with new technologies like " │ ├ inlineBlock │ │ └ Item "LQlcO4LCTYaOrfj2A705DQ" (item_type: "VGXgXav9SwG5P48frGrFxA") │ │ ├ product_name: "AI Shopping Assistant" │ │ ├ price: 99.99 │ │ └ affiliate_url: "https://old-domain.com/product?ref=blog&source=article_mention" │ └ span " transforming how customers shop online." ├ block │ └ Item "FqTxaDO8TJmtkoTgDjbK8Q" (item_type: "d-CHYg-rShOt3kiL6ZN1yA") │ ├ title: "Join the Revolution" │ ├ description: "Stay ahead of the curve with our e-commerce insights." │ ├ button_text: "Subscribe Now" │ └ button_url: "https://new-domain.com/subscribe" ├ block │ └ Item "JwT0hvgiR4WB0-SO9QG7Ig" (item_type: "I8Q6k-HqQmaZ498WKtvFbg") │ ├ title: "E-commerce Innovation Gallery" │ └ images │ ├ [0] │ │ ├ upload_id: "eLVHtrefRUq7qkVMpzG6mQ" │ │ ├ alt: "Modern e-commerce interface" │ │ └ title: "Next-gen Shopping" │ └ [1] │ ├ upload_id: "UFIyIRQFT1GQ7YAG34ub5w" │ ├ alt: "AI-powered recommendations" │ └ title: "Smart Product Discovery" └ block └ Item "czplSGgiSnizmFZ7gMT-_g" (item_type: "d-CHYg-rShOt3kiL6ZN1yA") ├ title: "Join the Revolution" ├ description: "Stay ahead of the curve with our e-commerce insights." ├ button_text: "Subscribe Now" └ button_url: "https://old-domain.com/subscribe"Block & node helpers
Curated index of the helpers used above.
Block helpers
Used by all three block field types. Full reference: block-processing-utilities.
| Need | Helper |
|---|---|
| Build a block (create / edit) | buildBlockRecord<T>(...) |
| Clone a block (deep copy, IDs stripped) | duplicateBlockRecord<T>(block, schemaRepository) |
| Inspect a record or block (debug) | inspectItem(record) |
| Inline narrowing on a block union | block.__itemTypeId === "..." |
Type-guard predicate for Array#filter / Array#find | isBlockOfType(id) |
| Recurse into every block (any depth, any field) | visitBlocks* / mapBlocks* / filterBlocks* / findAllBlocks* / reduceBlocks* / someBlocks* / everyBlocks* InNonLocalizedFieldValue |
Structured-text node helpers
Used only by structured_text. Each helper has an *Async mirror (mapNodes → mapNodesAsync) for async callbacks. Full reference: tree-manipulation-utilities.
| Need | Helper |
|---|---|
| Narrow a node to a kind | isParagraph, isHeading, isSpan, isLink, isItemLink, isInlineItem, isBlock, isInlineBlock, isList, ... |
| Narrow a block / inline-block node to a specific model in one step | isBlockWithItemOfType(id), isInlineBlockWithItemOfType(id) |
| Walk every node (side effect) | forEachNode |
| Transform every node (1:1, splat into siblings, or drop) | mapNodes |
| Find first / collect every match | findFirstNode, collectNodes |
| Fold to a single value | reduceNodes |
| Short-circuit checks | someNode, everyNode |
| ASCII-tree debug | inspect |
Updating Localized Fields
➡️ Before proceeding, ensure you have read the general guide on Localization
When you send an update request, the API follows these strict rules.
Rule 1: To change a locale value, send the whole set
When you update a translated field, you must provide the entire object for that field, including all the languages you want to keep unchanged. You can't just send the one language you're changing.
- Correct: To update the Italian title, you send both English and Italian:
{"title": {"en": "Hello World","it": "Ciao a tutti! (Updated)"}}
- Incorrect: If you only send the Italian value, the API will assume you want to delete the English one!
Rule 2: To add/remove a language, send all translated fields
This is the only time you can't just send the one field you're changing. To add or remove a language from an entire record, you must include all translated fields in your request. This is to enforce the Locale Sync Rule and ensure all fields remain consistent.
- Example: To add French to a blog post that already has a translated
titleandcontent, your request must include both fields with the newfrlocale.
Rule 3: Limited permissions? Only send what you can manage
If your API key only has permission for certain languages (e.g., only English), you must only include those languages in your update. The system is smart and will automatically protect and preserve the content for the languages you can't access (like Italian or French).
Update scenarios at a glance
This table shows what happens in different situations. The key takeaway is that your update payload defines the new final state for the languages you are allowed to manage.
| Your Role manages | Record currently Has | Your payload sends | Result |
|---|---|---|---|
| English | English | English | ✅ English is updated. |
| English, Italian | English | English, Italian | ✅ English is updated. ➕ Italian is added. |
| English, Italian | English, Italian | English | ✅ English is updated. ➖ Italian is removed. |
| English, Italian | English, Italian | English, Italian | ✅ English is updated. ✅ Italian is updated. |
| Eng, Ita, Fre | English, Italian | English, French | ✅ English is updated. ➖ Italian is removed. ➕ French is added. |
| English | English, Italian | English | ✅ English is updated. 🛡️ Italian is preserved. |
| English, Italian | English, French | English, Italian | ✅ English is updated. 🛡️ French is preserved. ➕ Italian is added. |
| English, Italian | English, French | Italian | ➖ English is removed. 🛡️ French is preserved. ➕ Italian is added. |
Block fields
The rules about localization work in combination with the rules for updating blocks: you use full block objects to create/update and block IDs to leave unchanged, but you do so within the object for a specific locale.
Example: Updating a block in one locale
This payload updates the title of an existing block in the en locale, while leaving the second English block and all Italian blocks untouched. The it locale needs to be included in the payload, or the Italian locale will be deleted!
{ "content_blocks": { "en": [ { "id": "dhVR2HqgRVCTGFi0bWqLqA", "type": "item", "attributes": { "title": "Updated English Title" } }, "kL9mN3pQrStUvWxYzAbCdE" ], "it": [ "dhVR2HqgRVCTGFi_0bWqLqA", "kL9mN3pQrStUvWxYzAbCdE" ] }}Example: Adding a new block to one locale
This payload adds a new block to the it locale only. The en locale needs to be included in the payload, or the Italian locale will be deleted!
{ "content_blocks": { "en": [ "dhVR2HqgRVCTGFi_0bWqLqA", "kL9mN3pQrStUvWxYzAbCdE" ], "it": [ "fG8hI1jKlMnOpQrStUvWxY", { "type": "item", "attributes": { "title": "Nuovo Blocco" }, "relationships": { "item_type": { "data": { "id": "BxZ9Y2aKQVeTnM4hP8wLpD", "type": "item_type" } } } }, "dhVR2HqgRVCTGFi0bWqLqA" ] }}Example: Adding a new locale
To add a new locale to an existing record, you must provide values for all localized fields for that new locale, and include existing locales that you want to preserve.
{ "title": { "en": "English Title", "fr": "Titre Français", }, "content_blocks": { "en": [ "dhVR2HqgRVCTGFi_0bWqLqA", "kL9mN3pQrStUvWxYzAbCdE" ], "fr": [ { "type": "item", "attributes": { "title": "Nouveau Bloc Français" }, "relationships": { "item_type": { "data": { "id": "BxZ9Y2aKQVeTnM4hP8wLpD", "type": "item_type" } } } } ] }}Helpers shipped with our JS CMA clients let you skip the "does this field have a locale object or just a value" branching when reading or transforming field values — jump to Unified locale helpers.
Adds the de locale to a record that currently has en and it, in an environment whose locales are ['en', 'it', 'de']. Per Rule 2 above, the payload must include every localized field, with the existing locales preserved alongside the new one. Requires all_locales_required to be false on the model.
import { buildClient, inspectItem } from "@datocms/cma-client-node";import type * as Schema from "./schema.js";
/* * Article * ├─ author: string * ├─ title: string (localized) * └─ content: text (localized) */
async function run() { // Make sure the API token has access to the CMA, and is stored securely const client = buildClient({ apiToken: process.env.DATOCMS_API_TOKEN });
const item = await client.items.update<Schema.Article>( "T4m4tPymSACFzsqbZS65WA", { title: { en: "My title", it: "Il mio titolo", de: "Mein Titel", }, content: { en: "Schema.Article content", it: "Contenuto articolo", de: "Artikelinhalt", }, }, );
console.log(inspectItem(item));}
run();└ Item "T4m4tPymSACFzsqbZS65WA" (item_type: "UZyfjdBES8y2W2ruMEHSoA") ├ author: "Stefano Verna" ├ title │ ├ de: "Mein Titel" │ ├ en: "My title" │ └ it: "Il mio titolo" └ content ├ de: "Artikelinhalt" ├ en: "Article content" └ it: "Contenuto articolo"If the all_locales_required option in a model is turned off, then its records do not need all environment's locales to be defined for localized fields, so you're free to add/remove locales during an update operation.
This example demonstrates two approaches for removing a locale from records:
-
When schema is known: When you know the exact structure of your models, you can use
ItemTypeDefinitions to work with full type safety. This approach is ideal for specific, targeted operations. -
When schema is unknown: When you need to work with models dynamically (without knowing their structure ahead of time), you can use
client.fields.list()to discover field definitions at runtime. This approach is perfect for bulk operations across multiple models.
Both approaches remove the it locale by omitting the unwanted locale from all localized fields while preserving other locales and non-localized fields.
import type { ApiTypes } from "@datocms/cma-client-node";import { buildClient, inspectItem, isLocalized, type LocalizedFieldValue,} from "@datocms/cma-client-node";import lodash from "lodash";import type * as Schema from "./schema.js";
/* * Article * ├─ title: string (localized) * ├─ content: structured_text (localized) * ├─ cta_block: single_block * │ └─ CtaBlock: title, button_text, button_url * └─ cover_image: file * * ArticleCopy * ├─ title: string (localized) * ├─ content: structured_text (localized) * ├─ cta_block: single_block * │ └─ CtaBlock * └─ cover_image: file */
// Make sure the API token has access to the CMA, and is stored securelyconst client = buildClient({ apiToken: process.env.DATOCMS_API_TOKEN });
async function removeLocaleWhenSchemaIsKnown() { // First, fetch the existing record to get current values const existingRecord = await client.items.find<Schema.Article>( "T4m4tPymSACFzsqbZS65WA", );
console.log("-- BEFORE UPDATE --"); console.log(inspectItem(existingRecord));
// Update the record to remove the "it" locale by omitting it from all localized fields // Using lodash omit() for clean, readable locale removal const updatedRecord = await client.items.update<Schema.Article>( "T4m4tPymSACFzsqbZS65WA", { title: lodash.omit(existingRecord.title, "it"), content: lodash.omit(existingRecord.content, "it"), // Do not pass non-localized fields (ie. cover_image, cta_block), as we want to keep them unchanged }, );
console.log("-- AFTER LOCALE REMOVAL --"); console.log(inspectItem(updatedRecord));}
// When you don't know the model structure ahead of time,// you can dynamically load the fields and perform the same operationasync function removeLocaleWhenSchemaIsUnknown() { // Get the model fields const fields = await client.fields.list("ZV0o9497SsqWxQR8HEQddw");
// Filter to only localized fields using the isLocalized helper const localizedFields = fields.filter(isLocalized);
// Process all records of this model type for await (const record of client.items.listPagedIterator({ filter: { type: "ZV0o9497SsqWxQR8HEQddw" }, })) { const updatePayload: ApiTypes.ItemUpdateSchema = {};
// Build update payload by processing each localized field for (const field of localizedFields) { const fieldValue = record[field.api_key] as LocalizedFieldValue;
// Remove the "it" locale from each field's localized values updatePayload[field.api_key] = lodash.omit(fieldValue, "it"); }
// Update the record with the modified locale data await client.items.update(record.id, updatePayload); }
console.log("Removed 'it' locale from all records of the model");}
async function run() { // Run both examples await removeLocaleWhenSchemaIsKnown(); await removeLocaleWhenSchemaIsUnknown();}
run();-- BEFORE UPDATE --└ Item "T4m4tPymSACFzsqbZS65WA" (item_type: "UZyfjdBES8y2W2ruMEHSoA") ├ title │ ├ de: "Content-Management verstehen" │ ├ en: "Understanding Content Management" │ └ it: "Capire la gestione dei contenuti" ├ content │ ├ de │ │ └ paragraph │ │ └ span "Ein umfassender Leitfaden für moderne Content-Management-Systeme und ..." │ ├ en │ │ └ paragraph │ │ └ span "A comprehensive guide to modern content management systems and best p..." │ └ it │ └ paragraph │ └ span "Una guida completa ai sistemi di gestione dei contenuti moderni e all..." ├ cta_block: "ZPfQFuaqTn2cdoQnPSsu_g" └ cover_image └ upload_id: "adCusKKeRPO5wtjrIIcGjw"
-- AFTER LOCALE REMOVAL --└ Item "T4m4tPymSACFzsqbZS65WA" (item_type: "UZyfjdBES8y2W2ruMEHSoA") ├ title │ ├ de: "Content-Management verstehen" │ └ en: "Understanding Content Management" ├ content │ ├ de │ │ └ paragraph │ │ └ span "Ein umfassender Leitfaden für moderne Content-Management-Systeme und ..." │ └ en │ └ paragraph │ └ span "A comprehensive guide to modern content management systems and best p..." ├ cta_block: "ZPfQFuaqTn2cdoQnPSsu_g" └ cover_image └ upload_id: "adCusKKeRPO5wtjrIIcGjw"
Removed 'it' locale from all records of the modelCopies all localized content from en to en-AT across every model in the project — useful for seeding a new locale, region-specific variations (e.g. UK → Austrian English), or fallback content for incomplete translations.
The script iterates through every model, walks each localized field with mapNormalizedFieldValuesAsync, and recurses into nested blocks with mapBlocksInNonLocalizedFieldValue to strip their IDs — that way the target locale gets fresh block instances instead of references to the source ones.
import assert from "node:assert";import type { ApiTypes } from "@datocms/cma-client-node";import { buildClient, inspectItem, isItemWithOptionalMeta, isLocalized, type LocalizedFieldValue, mapBlocksInNonLocalizedFieldValue, mapNormalizedFieldValuesAsync, SchemaRepository,} from "@datocms/cma-client-node";
async function run() { // Make sure the API token has access to the CMA, and is stored securely const client = buildClient({ apiToken: process.env.DATOCMS_API_TOKEN });
const schemaRepository = new SchemaRepository(client);
for (const model of await schemaRepository.getAllModels()) { const fields = await schemaRepository.getItemTypeFields(model); const localizedFields = fields.filter(isLocalized);
// Iterating across every model discovered at runtime, so no single // `<Schema.X>` generic fits — keep `listPagedIterator` untyped and narrow // field values dynamically below. for await (const record of client.items.listPagedIterator({ filter: { type: model.api_key }, version: "current", nested: true, })) { const updatePayload: ApiTypes.ItemUpdateSchema = {}; let hasChanges = false;
for (const field of localizedFields) { const fieldValueWithNestedBlocks = record[ field.api_key ] as LocalizedFieldValue;
if (!fieldValueWithNestedBlocks["en"]) { continue; }
const newFieldValue = (await mapNormalizedFieldValuesAsync( fieldValueWithNestedBlocks, field, async (_locale, fieldValueForLocale) => { return mapBlocksInNonLocalizedFieldValue( fieldValueForLocale, field.field_type, schemaRepository, (block) => { assert(isItemWithOptionalMeta(block)); return block.id; }, ); }, )) as LocalizedFieldValue;
// Strip IDs from cloned blocks so en-AT gets fresh instances rather // than references to the en blocks. newFieldValue["en-AT"] = await mapBlocksInNonLocalizedFieldValue( fieldValueWithNestedBlocks["en"], field.field_type, schemaRepository, (block) => { assert(isItemWithOptionalMeta(block)); const { id, ...blockWithoutId } = block; return blockWithoutId; }, );
updatePayload[field.api_key] = newFieldValue;
hasChanges = true; }
if (hasChanges) { console.log("-- EXISTING RECORD --"); console.log(inspectItem(record));
console.log("-- UPDATE PAYLOAD --"); console.log(inspectItem(updatePayload));
await client.items.update(record.id, updatePayload);
const nestedRecord = await client.items.find(record.id, { nested: true, }); console.log("-- RECORD AFTER UPDATE --"); console.log(inspectItem(nestedRecord)); } } }}
run();-- EXISTING RECORD --└ Item "Bz0dHLjeRuCW10fJl1GF0w" (item_type: "UZyfjdBES8y2W2ruMEHSoA") ├ name │ ├ de: "Premium Kabellose Kopfhörer" │ └ en: "Premium Wireless Headphones" ├ description │ ├ de: "Erleben Sie kristallklaren Klang mit unseren hochwertigen kabellosen Kopfhöre..." │ └ en: "Experience crystal-clear audio with our top-of-the-line wireless headphones f..." ├ features │ ├ de │ │ ├ [0] Item "YazSxGr2TlKLJZjJmyhg0Q" (item_type: "T4m4tPymSACFzsqbZS65WA") │ │ │ ├ title: "Aktive Geräuschunterdrückung" │ │ │ └ description: "Blockieren Sie unerwünschte Geräusche mit unserer fortschrittlichen ANC-Techn..." │ │ └ [1] Item "D1Zh8Ff2SaC1CCG67Ic1sQ" (item_type: "T4m4tPymSACFzsqbZS65WA") │ │ ├ title: "30 Stunden Akkulaufzeit" │ │ └ description: "Ganztägiges Hören mit Schnellladefunktion." │ └ en │ ├ [0] Item "HqZZmo8sRKuKMfaUZbkNig" (item_type: "T4m4tPymSACFzsqbZS65WA") │ │ ├ title: "Active Noise Cancellation" │ │ └ description: "Block out unwanted noise with our advanced ANC technology." │ └ [1] Item "NKaGQ1AUQZSfHHhL0c3eLA" (item_type: "T4m4tPymSACFzsqbZS65WA") │ ├ title: "30-Hour Battery Life" │ └ description: "All-day listening with fast charging capabilities." └ price: 299.99
-- UPDATE PAYLOAD --└ Item ├ description │ ├ de: "Erleben Sie kristallklaren Klang mit unseren hochwertigen kabellosen Kopfhöre..." │ ├ en: "Experience crystal-clear audio with our top-of-the-line wireless headphones f..." │ └ en-AT: "Experience crystal-clear audio with our top-of-the-line wireless headphones f..." ├ features │ ├ de │ │ ├ [0] "YazSxGr2TlKLJZjJmyhg0Q" │ │ └ [1] "D1Zh8Ff2SaC1CCG67Ic1sQ" │ ├ en │ │ ├ [0] "HqZZmo8sRKuKMfaUZbkNig" │ │ └ [1] "NKaGQ1AUQZSfHHhL0c3eLA" │ └ en-AT │ ├ [0] Item (item_type: "T4m4tPymSACFzsqbZS65WA") │ │ ├ title: "Active Noise Cancellation" │ │ └ description: "Block out unwanted noise with our advanced ANC technology." │ └ [1] Item (item_type: "T4m4tPymSACFzsqbZS65WA") │ ├ title: "30-Hour Battery Life" │ └ description: "All-day listening with fast charging capabilities." └ name ├ de: "Premium Kabellose Kopfhörer" ├ en: "Premium Wireless Headphones" └ en-AT: "Premium Wireless Headphones"
-- RECORD AFTER UPDATE --└ Item "Bz0dHLjeRuCW10fJl1GF0w" (item_type: "UZyfjdBES8y2W2ruMEHSoA") ├ name │ ├ de: "Premium Kabellose Kopfhörer" │ ├ en: "Premium Wireless Headphones" │ └ en-AT: "Premium Wireless Headphones" ├ description │ ├ de: "Erleben Sie kristallklaren Klang mit unseren hochwertigen kabellosen Kopfhöre..." │ ├ en: "Experience crystal-clear audio with our top-of-the-line wireless headphones f..." │ └ en-AT: "Experience crystal-clear audio with our top-of-the-line wireless headphones f..." ├ features │ ├ de │ │ ├ [0] Item "YazSxGr2TlKLJZjJmyhg0Q" (item_type: "T4m4tPymSACFzsqbZS65WA") │ │ │ ├ title: "Aktive Geräuschunterdrückung" │ │ │ └ description: "Blockieren Sie unerwünschte Geräusche mit unserer fortschrittlichen ANC-Techn..." │ │ └ [1] Item "D1Zh8Ff2SaC1CCG67Ic1sQ" (item_type: "T4m4tPymSACFzsqbZS65WA") │ │ ├ title: "30 Stunden Akkulaufzeit" │ │ └ description: "Ganztägiges Hören mit Schnellladefunktion." │ ├ en │ │ ├ [0] Item "HqZZmo8sRKuKMfaUZbkNig" (item_type: "T4m4tPymSACFzsqbZS65WA") │ │ │ ├ title: "Active Noise Cancellation" │ │ │ └ description: "Block out unwanted noise with our advanced ANC technology." │ │ └ [1] Item "NKaGQ1AUQZSfHHhL0c3eLA" (item_type: "T4m4tPymSACFzsqbZS65WA") │ │ ├ title: "30-Hour Battery Life" │ │ └ description: "All-day listening with fast charging capabilities." │ └ en-AT │ ├ [0] Item "Txk_qqFJSL6VP3wFzmE_2w" (item_type: "T4m4tPymSACFzsqbZS65WA") │ │ ├ title: "Active Noise Cancellation" │ │ └ description: "Block out unwanted noise with our advanced ANC technology." │ └ [1] Item "eValhxXxTZe-6FW-mwe80g" (item_type: "T4m4tPymSACFzsqbZS65WA") │ ├ title: "30-Hour Battery Life" │ └ description: "All-day listening with fast charging capabilities." └ price: 299.99Unified locale helpers
These utilities let you treat localized and non-localized field values with the same code path — no branching on "does this field have a locale object or just a value". Each helper has an *Async mirror (mapNormalizedFieldValues → mapNormalizedFieldValuesAsync) for async callbacks. See the full helper reference for signatures and examples.
mapNormalizedFieldValues(): apply a transformation to each locale (or to the single value on non-localized fields)filterNormalizedFieldValues(): keep only locales / values matching a predicatevisitNormalizedFieldValues(): run a side effect for each locale / valuesomeNormalizedFieldValues():trueif at least one locale / value matcheseveryNormalizedFieldValue():trueif all locales / values matchtoNormalizedFieldValueEntries()/fromNormalizedFieldValueEntries(): convert to / from a unified[locale, value][]shape for iteration
Bulk block operations
Sometimes, you need to perform mass operations on any block of a specific kind, regardless of where they're embedded in your content structure — whether in Modular Content fields, Single Block fields, or deeply nested within Structured Text documents. In these cases, manually traversing each record and field would be extremely time-consuming and error-prone.
DatoCMS provides powerful utilities that can systematically discover, traverse, and manipulate blocks across your entire content hierarchy. These utilities handle the complexity of localized content, nested structures, and different field types automatically, making what would otherwise be a complex operation straightforward and reliable.
Edits specific blocks wherever they're embedded — Modular Content, Single Block, or Structured Text fields, including deeply nested structures and localized content.
To avoid scanning the whole project, the script discovers only the models that can (directly or transitively) embed the target block via SchemaRepository.getRawModelsEmbeddingBlocks(), then walks each record with mapNormalizedFieldValuesAsync and mapBlocksInNonLocalizedFieldValue. In this case, every CTA block gets its style set to "primary" for high-intent copy and "muted" otherwise, based on the button text.
import assert from "node:assert";import { type ApiTypes, buildBlockRecord, buildClient, inspectItem, isItemWithOptionalMeta, mapBlocksInNonLocalizedFieldValue, mapNormalizedFieldValuesAsync, type RawApiTypes, SchemaRepository,} from "@datocms/cma-client-node";import * as Schema from "./schema.js";
/* * Article * ├─ title: string (localized) * ├─ content: structured_text (localized) * │ └─ CtaBlock: title, description, button_text, button_url, style * └─ sidebar: rich_text * └─ CtaBlock * * LandingPage * └─ hero_cta: single_block * └─ CtaBlock */
// Simplified style decision logicfunction computeCtaStyle(text: string | null): "primary" | "muted" { if (!text) return "muted"; return /buy|get started|start free|sign up|upgrade/i.test(text) ? "primary" : "muted";}
// Make sure the API token has access to the CMA, and is stored securelyconst client = buildClient({ apiToken: process.env.DATOCMS_API_TOKEN });
async function run() { const schemaRepository = new SchemaRepository(client); const ctaBlockModel = await schemaRepository.getItemTypeById( Schema.CtaBlock.ID, );
// 1. Find all models that can embed CTA blocks (directly or indirectly) const modelsEmbeddingCtas = await schemaRepository.getModelsEmbeddingBlocks([ ctaBlockModel, ]);
// 2. Process each model and its records for (const model of modelsEmbeddingCtas) { console.log( `\n📋 Processing records of model: ${model.name} (${model.api_key})`, );
const fields = await schemaRepository.getItemTypeFields(model);
// This script iterates records across every model discovered at runtime, // so there is no single `<Schema.X>` generic that fits — we keep // `rawListPagedIterator` untyped here and narrow block values dynamically // below. Per-model scripts should always pass the generated marker. for await (const record of client.items.rawListPagedIterator({ filter: { type: model.id }, version: "current", nested: true, // Get full block objects })) { console.log(`\n--- Processing ${record.id} ---`); console.log("BEFORE:"); console.log(inspectItem(record));
const updatedAttributes: ApiTypes.ItemUpdateSchema = {};
// 3. Use mapNormalizedFieldValuesAsync to handle localized/non-localized uniformly for (const field of fields) { const fieldValue = record.attributes[field.api_key];
let fieldHasChanges = false;
const updatedFieldValue = await mapNormalizedFieldValuesAsync( fieldValue, field, async (_locale, normalizedFieldValue) => mapBlocksInNonLocalizedFieldValue( normalizedFieldValue, field.field_type, schemaRepository, (block) => { assert(isItemWithOptionalMeta(block));
if (block.__itemTypeId !== Schema.CtaBlock.ID) { return block.id; // Keep non-CTA blocks as is }
// The raw iterator yields records (and their embedded blocks) // in the raw API shape; narrow to this specific block model // via the ItemInNestedResponse schema indexed by Schema.CtaBlock. const ctaBlock = block as RawApiTypes.ItemInNestedResponse<Schema.CtaBlock>; const currentStyle = ctaBlock.attributes.style; const desiredStyle = computeCtaStyle( ctaBlock.attributes.button_text, );
if (currentStyle !== desiredStyle) { fieldHasChanges = true;
// Return an updated block record with new style return buildBlockRecord<Schema.CtaBlock>({ id: ctaBlock.id, style: desiredStyle, }); }
return block.id; // No change needed }, ), );
if (fieldHasChanges) { updatedAttributes[field.api_key] = updatedFieldValue; } }
// 4. Update the record if there were changes if (Object.keys(updatedAttributes).length > 0) { const updatedRecord = await client.items.update( record.id, updatedAttributes, );
console.log("AFTER:"); await inspectItemWithNestedBlocks(updatedRecord); } else { console.log("✨ No changes needed for this record"); } } }}
run();
async function inspectItemWithNestedBlocks(item: ApiTypes.Item) { const itemWithNestedBlocks = await client.items.find(item, { nested: true }); console.log(inspectItem(itemWithNestedBlocks));}📋 Processing records of model: Landing Page (landing_page)
--- Processing IwcHsSQ5SSa2LYzpk_Ddjw ---BEFORE:└ Item "IwcHsSQ5SSa2LYzpk_Ddjw" (item_type: "KUz2pYAvQvOWqv3dVwVw3w") └ hero_cta └ Item "fnclskI4RG25uLQC92XE5g" (item_type: "DC2XVF6BTjGBgQaoaih6Og") ├ title: "Transform Your Business" ├ description: "Take the next step forward" ├ button_text: "Get started" ├ button_url: "/start" └ style: "muted"
AFTER:└ Item "IwcHsSQ5SSa2LYzpk_Ddjw" (item_type: "KUz2pYAvQvOWqv3dVwVw3w") └ hero_cta └ Item "fnclskI4RG25uLQC92XE5g" (item_type: "DC2XVF6BTjGBgQaoaih6Og") ├ title: "Transform Your Business" ├ description: "Take the next step forward" ├ button_text: "Get started" ├ button_url: "/start" └ style: "primary"
📋 Processing records of model: Article (article)
--- Processing JPplplyPTMKpCbB-wipxeA ---BEFORE:└ Item "JPplplyPTMKpCbB-wipxeA" (item_type: "ONxSjA4WTWaoNJY2zokUoQ") ├ title │ ├ en: "Sample Article" │ └ it: "Articolo di Esempio" ├ content │ ├ en │ │ ├ paragraph │ │ │ └ span "Introduction text" │ │ ├ block │ │ │ └ Item "BOps1UFgSU-CSm-fRjoqCA" (item_type: "DC2XVF6BTjGBgQaoaih6Og") │ │ │ ├ title: "Join Our Platform" │ │ │ ├ description: "Start your journey today" │ │ │ ├ button_text: "Sign up" │ │ │ ├ button_url: "/signup" │ │ │ └ style: "muted" │ │ └ paragraph │ │ └ span "Conclusion text" │ └ it │ ├ paragraph │ │ └ span "Testo introduttivo" │ └ block │ └ Item "SSVQgIbZS_uoPQWg-qYzWg" (item_type: "DC2XVF6BTjGBgQaoaih6Og") │ ├ title: "Scopri di Più" │ ├ description: "Leggi la nostra guida" │ ├ button_text: "Learn more" │ ├ button_url: "/guide" │ └ style: "primary" └ sidebar └ [0] Item "f86JBAwxTsasu7J3XxXqJg" (item_type: "DC2XVF6BTjGBgQaoaih6Og") ├ title: "Special Offer" ├ description: "Limited time promotion" ├ button_text: "Buy now" ├ button_url: "/buy" └ style: "muted"
AFTER:└ Item "JPplplyPTMKpCbB-wipxeA" (item_type: "ONxSjA4WTWaoNJY2zokUoQ") ├ title │ ├ en: "Sample Article" │ └ it: "Articolo di Esempio" ├ content │ ├ en │ │ ├ paragraph │ │ │ └ span "Introduction text" │ │ ├ block │ │ │ └ Item "BOps1UFgSU-CSm-fRjoqCA" (item_type: "DC2XVF6BTjGBgQaoaih6Og") │ │ │ ├ title: "Join Our Platform" │ │ │ ├ description: "Start your journey today" │ │ │ ├ button_text: "Sign up" │ │ │ ├ button_url: "/signup" │ │ │ └ style: "primary" │ │ └ paragraph │ │ └ span "Conclusion text" │ └ it │ ├ paragraph │ │ └ span "Testo introduttivo" │ └ block │ └ Item "SSVQgIbZS_uoPQWg-qYzWg" (item_type: "DC2XVF6BTjGBgQaoaih6Og") │ ├ title: "Scopri di Più" │ ├ description: "Leggi la nostra guida" │ ├ button_text: "Learn more" │ ├ button_url: "/guide" │ └ style: "muted" └ sidebar └ [0] Item "f86JBAwxTsasu7J3XxXqJg" (item_type: "DC2XVF6BTjGBgQaoaih6Og") ├ title: "Special Offer" ├ description: "Limited time promotion" ├ button_text: "Buy now" ├ button_url: "/buy" └ style: "primary"Removes specific blocks wherever they're embedded — Modular Content, Single Block, or Structured Text fields, including deeply nested structures and localized content.
To avoid scanning the whole project, the script discovers only the models that can (directly or transitively) embed the target block via SchemaRepository.getRawModelsEmbeddingBlocks(), then walks each record with mapNormalizedFieldValuesAsync and filterBlocksInNonLocalizedFieldValue. The removal predicate is an async check (mocked here as an ecommerce SKU lookup), so the same shape works for any external decision.
import assert from "node:assert";import { type ApiTypes, buildClient, filterBlocksInNonLocalizedFieldValue, inspectItem, isItemWithOptionalMeta, mapNormalizedFieldValuesAsync, type RawApiTypes, SchemaRepository,} from "@datocms/cma-client-node";import * as Schema from "./schema.js";
/* * Article * ├─ title: string (localized) * ├─ content: structured_text (localized) * │ └─ ProductBlock: sku * └─ sidebar: rich_text * └─ ProductBlock * * ProductPage * └─ featured_product: single_block * └─ ProductBlock */
// Mock external ecommerce system checkasync function isValidSKU(sku: string | null): Promise<boolean> { // For demo purposes, consider SKUs starting with "INVALID" as discontinued return Boolean(sku && !sku.startsWith("INVALID"));}
async function run() { // Make sure the API token has access to the CMA, and is stored securely const client = buildClient({ apiToken: process.env.DATOCMS_API_TOKEN });
const schemaRepository = new SchemaRepository(client); const productBlockModel = await schemaRepository.getItemTypeById( Schema.ProductBlock.ID, );
// 1. Find all models that can embed Product blocks (directly or indirectly) const modelsEmbeddingProductBlocks = await schemaRepository.getModelsEmbeddingBlocks([productBlockModel]);
// 2. Process each model and its records for (const model of modelsEmbeddingProductBlocks) { console.log( `\n📋 Processing records of model: ${model.name} (${model.api_key})`, );
const fields = await schemaRepository.getItemTypeFields(model);
// This script iterates records across every model discovered at runtime, // so there is no single `<Schema.X>` generic that fits — we keep // `rawListPagedIterator` untyped here and narrow block values dynamically // below. Per-model scripts should always pass the generated marker. for await (const record of client.items.rawListPagedIterator({ filter: { type: model.id }, version: "current", nested: true, // Get full block objects })) { console.log(`\n--- Processing ${record.id} ---`); console.log("BEFORE:"); console.log(inspectItem(record));
const updatedAttributes: ApiTypes.ItemUpdateSchema = {};
// 3. Use mapNormalizedFieldValuesAsync to handle localized/non-localized fields uniformly for (const field of fields) { const fieldValue = record.attributes[field.api_key];
let fieldHasChanges = false;
const updatedFieldValue = await mapNormalizedFieldValuesAsync( fieldValue, field, async (_locale, normalizedFieldValue) => { // 4. Use filterBlocksInNonLocalizedFieldValue to recursively filter blocks const filteredValue = await filterBlocksInNonLocalizedFieldValue( normalizedFieldValue, field.field_type, schemaRepository, async (block) => { assert(isItemWithOptionalMeta(block));
// Only check Product blocks if (block.__itemTypeId !== Schema.ProductBlock.ID) { return true; // Keep other blocks }
// The raw iterator yields records (and their embedded blocks) // in the raw API shape; narrow to this specific block model // via the ItemInNestedResponse schema indexed by Schema.ProductBlock. const productBlock = block as RawApiTypes.ItemInNestedResponse<Schema.ProductBlock>;
// Check if the product SKU is still valid in external system const isValid = await isValidSKU(productBlock.attributes.sku);
if (!isValid) { fieldHasChanges = true; }
return isValid; }, );
return filteredValue; }, );
if (fieldHasChanges) { updatedAttributes[field.api_key] = updatedFieldValue; } }
// 5. Update the record if there were changes if (Object.keys(updatedAttributes).length > 0) { const updatedRecord = await client.items.update( record.id, updatedAttributes, );
console.log("AFTER:"); console.log(inspectItem(updatedRecord)); } else { console.log("✨ No changes needed for this record"); } } }}
run();📋 Processing records of model: Product Page (product_page)
--- Processing RZfnYc3iSya7yYaTxoW0VA ---BEFORE:└ Item "RZfnYc3iSya7yYaTxoW0VA" (item_type: "KUz2pYAvQvOWqv3dVwVw3w") └ featured_product └ Item "XayqICFqQEyEqGFypcK07w" (item_type: "DC2XVF6BTjGBgQaoaih6Og") └ sku: "INVALID-SKU-004"
AFTER:└ Item "RZfnYc3iSya7yYaTxoW0VA" (item_type: "KUz2pYAvQvOWqv3dVwVw3w") └ featured_product: null
📋 Processing records of model: Article (article)
--- Processing Iaa0ZiZMSjCqeFWfs3JeuQ ---BEFORE:└ Item "Iaa0ZiZMSjCqeFWfs3JeuQ" (item_type: "ONxSjA4WTWaoNJY2zokUoQ") ├ title │ ├ en: "Sample Article" │ └ it: "Articolo di Esempio" ├ content │ ├ en │ │ ├ paragraph │ │ │ └ span "Introduction text" │ │ ├ block │ │ │ └ Item "Z-xM-VpKTzytfo-5jzSlOw" (item_type: "DC2XVF6BTjGBgQaoaih6Og") │ │ │ └ sku: "INVALID-SKU-001" │ │ └ paragraph │ │ └ span "Conclusion text" │ └ it │ ├ paragraph │ │ └ span "Testo introduttivo" │ └ block │ └ Item "YoCq3DtzSa2NIbB6ARsiVw" (item_type: "DC2XVF6BTjGBgQaoaih6Og") │ └ sku: "VALID-SKU-002" └ sidebar └ [0] Item "Ulk5GEEoRGSURyyPNSBcow" (item_type: "DC2XVF6BTjGBgQaoaih6Og") └ sku: "INVALID-SKU-003"
AFTER:└ Item "Iaa0ZiZMSjCqeFWfs3JeuQ" (item_type: "ONxSjA4WTWaoNJY2zokUoQ") ├ title │ ├ en: "Sample Article" │ └ it: "Articolo di Esempio" ├ content │ ├ en │ │ ├ paragraph │ │ │ └ span "Introduction text" │ │ └ paragraph │ │ └ span "Conclusion text" │ └ it │ ├ paragraph │ │ └ span "Testo introduttivo" │ └ block "YoCq3DtzSa2NIbB6ARsiVw" └ sidebar: []Optimistic Locking
To prevent clients from accidentally overwriting each other's changes, the update endpoint supports optimistic locking. You can include the record's current version number in the meta object of your payload.
If the version on the server is newer than the one you provide, the API will reject the update with a 422 STALE_ITEM_VERSION error, indicating that the record has been modified since you last fetched it.
import { ApiError, type ApiTypes, buildClient } from "@datocms/cma-client-node";import type * as Schema from "./schema.js";
/* * Counter * ├─ counter: integer * └─ description: string */
// Make sure the API token has access to the CMA, and is stored securelyconst client = buildClient({ apiToken: process.env.DATOCMS_API_TOKEN });
async function run() { const itemId = "T4m4tPymSACFzsqbZS65WA";
console.log("🚀 Starting concurrent updates simulation...\n");
// Create two competing updates that will run concurrently const updateA = updateRecordWithRetry( itemId, (record) => ({ counter: (record.counter || 0) + 10, description: `Updated by Process A at ${new Date().toISOString()}`, }), "Process A", );
const updateB = updateRecordWithRetry( itemId, (record) => ({ counter: (record.counter || 0) + 5, description: `Updated by Process B at ${new Date().toISOString()}`, }), "Process B", );
try { // Run both updates concurrently - one will likely trigger STALE_ITEM_VERSION const [resultA, resultB] = await Promise.all([updateA, updateB]);
console.log("\n✅ Both updates completed successfully!"); console.log( "Final counter value:", Math.max(resultA.counter || 0, resultB.counter || 0), );
// Get the final state to see which update won const finalRecord = await client.items.find<Schema.Counter>(itemId); console.log("\nFinal record state:"); console.log("- Schema.Counter:", finalRecord.counter); console.log("- Description:", finalRecord.description); console.log("- Version:", finalRecord.meta.current_version); } catch (error) { console.error("❌ Unexpected error:", error); }}
async function updateRecordWithRetry( itemId: string, updateFunction: (record: ApiTypes.Item<Schema.Counter>) => { counter?: number; description?: string; }, operationName: string,) { // Get the current record const record = await client.items.find<Schema.Counter>(itemId);
console.log( `${operationName}: Got record version ${record.meta.current_version}`, );
try { // Apply the update with optimistic locking const updatedRecord = await client.items.update<Schema.Counter>(itemId, { ...updateFunction(record), meta: { current_version: record.meta.current_version }, });
console.log( `${operationName}: Update successful! New version: ${updatedRecord.meta.current_version}`, ); return updatedRecord; } catch (e) { // Handle STALE_ITEM_VERSION error by retrying if (e instanceof ApiError && e.findError("STALE_ITEM_VERSION")) { console.log( `${operationName}: ❌ STALE_ITEM_VERSION detected! Record was modified by another client.`, ); console.log(`${operationName}: 🔄 Retrying with fresh data...`);
// Recursive retry with exponential backoff await new Promise((resolve) => setTimeout(resolve, Math.random() * 100 + 50), ); return updateRecordWithRetry(itemId, updateFunction, operationName); }
throw e; }}
run();🚀 Starting concurrent updates simulation...
Process A: Got record version L80WevK_R6Gh6ijMyW0AkQProcess B: Got record version L80WevK_R6Gh6ijMyW0AkQProcess A: Update successful! New version: QHXWBDr7S9ipQo6_JjQpAgProcess B: ❌ STALE_ITEM_VERSION detected! Record was modified by another client.Process B: 🔄 Retrying with fresh data...Process B: Got record version QHXWBDr7S9ipQo6_JjQpAgProcess B: Update successful! New version: A4VMeUdtRT26NWd07g6pzw
✅ Both updates completed successfully!Final counter value: 15
Final record state:- Counter: 15- Description: Updated by Process B at 2025-09-26T08:25:53.088Z- Version: A4VMeUdtRT26NWd07g6pzwBody parameters
Date of creation
Date of first publication
The ID of the current record version (for optimistic locking, see the example)
"4234"
The new stage to move the record to
The entity (account/collaborator/access token/sso user) who created the record
Returns
Returns a resource object of type item