How resolution works
Here's the setup. Your block map registers two functions, product-card and catalog-product. Two blocks are saved: CurrentProduct, which is { "__resolveType": "catalog-product", "slug": "summer-shirt" }, and SummerCard, shown in step 1, which uses it (__resolveType names the function a block calls). As code, SummerCard means productCard({ title: "Summer collection", product: catalogProduct({ slug: "summer-shirt" }) }).
This page shows, step by step, how the SDK evaluates that block. Step through client.resolve("SummerCard") to watch the lookup rule at work, or switch to { run: false } to see what a read returns. product-card can return a descriptor or a React element (for frameworks that render React Server Components, such as the Next.js App Router); the second toggle compares them. This is an illustration, so it doesn't run any application code.
{
"__resolveType": "product-card",
"title": "Summer collection",
"product": {
"__resolveType": "CurrentProduct"
}
}The saved block references CurrentProduct, another saved block.
The rule in full
Blocks gives the rule in one paragraph. These are the details behind it:
- One namespace. Saved blocks, built-in blocks and your block map share one registry,
{ ...savedBlocks, ...builtIns, ...blocks }, so names should be unique. When a saved block has the same name as a block type, the function wins;deco checkreports the collision. - Inputs resolve concurrently. A function's inputs resolve before it runs, children before parents, and sibling blocks at the same time.
- One exception:
lazy. A lazy block is the only block whose input doesn't resolve first. The function gets() => Promise<T>instead, which resolvesvaluethe first time it's called and returns the same result after that. If it's never called, nothing inside it runs. If its value fails, calling the function rejects with that error; the function that called it is already running, so it handles the error itself. - Failures stop the parent. If a nested block fails (outside a
lazyblock), its parent never runs andresolvereturns the error, much as an outer call never runs when an inner one throws. The error'spathsays where in the tree it happened. - Overrides merge, then look up again. A reference with extra arguments expands to
{ ...saved, ...arguments }(see Override arguments), and the merged block goes through the rule again. - A name with no saved block fails.
client.resolve("Name")fails withNOT_FOUNDwhen nothing is saved under that name; a string is always a saved block's name, never a block type. - Results are returned as is. The CMS never walks a function's return value. This is also why a function that returns its input, like
seo, can't loop:__resolveTypeis stripped before the call, and whatever comes back is never looked up again. - Only data can loop. A saved-entry expansion is the one branch that applies the rule again, so two entries that reference each other, or an entry that references itself, would recurse forever. The CMS tracks the entries expanded on the current path and fails with
CYCLEthe moment one repeats, with the chain inerror.path. - Block functions should only read. They run whenever content is resolved: for a visitor, in a preview, or when an agent checks its edit. Put side effects in your own route handlers or server functions (see Call a client).
- Reading without running.
client.resolve(target, { run: false })stops after expansion: saved blocks are replaced and merged, and no function runs. It's useful for previews, tooling and debugging, and it's whatclient.listreturns by default.