Skip to content
decodecodeveloper docs
Storefront → Blocks → Reference

API reference

You're writing the request handler and need the exact signature of list or matchRoute. This page lists everything @decocms/blocks exports.

Everything here is imported from @decocms/blocks, except the instrumented fetch for upstream clients, in @decocms/blocks/fetch, track and AnalyticsScript, in @decocms/blocks/analytics, and encryptSecret, in @decocms/blocks/secrets. The built-in blocks, such as the matchers, multivariate and lazy, need no import. What creating the CMS means for your app is in Content and loaders.

createCMS(config)

createCMS returns a CMS: your block map plus your content. Create it once, at module scope, and ask it for a client per request: forRelease() for visitors, who see the current release, and forDraft(pointer) for drafts, where the pointer names the draft (see Draft pointers).

  • One revision per client. A client loads one revision on first use and reads only that revision afterwards; every later list or resolve reuses it. Create one client per request (why: One revision per response). client.revision() tells you which revision a client reads, and cms.forRevision(revision) returns a client pinned to it.
  • Results are memoized per client and per block-map object: build the map at module scope. An entry referenced three times runs its function once per client. Results are kept per function of the map you passed, so a map rebuilt inside your handler starts with an empty cache.
  • Clients are cheap: the content cache lives in the CMS, shared by every client.
  • Same config, same instance: see One instance per process.
function createCMS(config: {
  blocks: Blocks;               // your block functions; the CMS uses { ...builtIns, ...blocks }, so a key here overrides a built-in
  content: Snapshot | Loader;   // the content module (.deco/blocks.gen.ts), or any Loader
  interval?: number;            // ms between update() checks of a content source that has one; default DECO_CONTENT_INTERVAL or 60_000; minimum 60_000
 
  telemetry?: false | TelemetryConfig;   // where telemetry goes; see /next/telemetry
  preview?: { hosts?: string[] };        // the hosts content may allow previews on; see /next/releases-and-drafts#allow-previews-per-host
 
  secrets?: { key?: string };    // the private key that decrypts secret blocks, usually DECO_SECRETS_KEY; see /next/built-in-blocks#secrets
 
  // Hosted releases and drafts (optional), see /next/hosted
  site?: string;                // your site's ID, usually DECO_SITE
  token?: string;               // your site token (secret), usually DECO_SITE_TOKEN
}): CMS;
 
type TelemetryConfig = (
  | { site: string; token: string }                          // the hosted Deco CMS collector
  | { endpoint: string; headers?: Record<string, string> }   // any OpenTelemetry (OTLP/HTTP) collector
) & {
  limits?: { errorSampleRate?: number; traceSampleRate?: number };   // caps on the CMS block's telemetry section; defaults 0.1 and 0
};
 
interface CMS {
  forRelease(): Client;                         // a client reading the current release
  forDraft(pointer: string): Client;            // a client reading the draft a pointer names, with the variants it forces; a source without drafts behaves like the release (forced variants still apply); a draft that can't load makes every call return [null, error]
  forRevision(revision: string): Client;        // a client pinned to a revision it has served; an unknown revision behaves like the release
  update(): Promise<{ updated: boolean }>;      // ask the content source for newer content now; never throws
  settings(): Promise<EffectiveSettings>;       // the release's CMS settings, defaults filled in and caps applied; never fetches, never rejects
  draftPointer(request: RequestLike): Promise<string | null>;   // the request's draft pointer; null on a host previews aren't allowed on
  draftCookie(request: RequestLike): Promise<string | null>;    // the Set-Cookie value that starts or ends a preview; see Draft pointers
}
 
interface Client {
  resolve<T = unknown>(target: unknown, options?: { run?: boolean }): Promise<Result<T>>;   // run: false reads without running
  list<T = Block>(type: string, options?: ListOptions<T>): Promise<Result<T[]>>;
  revision(): Promise<string>;   // the revision this client reads (loads it on first use); rejects with LOADER_FAILED if the content can't load
}

content takes the content module or a Loader (see Configuring content). interval paces a content source that can change while the process runs (see Loaders); the content module never changes, so it ignores it. telemetry says where measurements go: false sends nothing, an object sends there, and when it's left out the CMS uses OTEL_EXPORTER_OTLP_ENDPOINT (and OTEL_EXPORTER_OTLP_HEADERS) if set, otherwise nothing (see Choose where telemetry goes). The top-level site and token load hosted releases and drafts only; with either of them unset, the CMS reads content only. They never send telemetry by themselves. secrets.key is the PEM private key that secret blocks decrypt with; without it, a secret block fails. preview.hosts is the most content may allow previews on, in the host pattern format; without it, content may allow any host, and without content either, every host may preview. createCMS throws on a pattern it can't read, since that's a bug in your code, not in content. The rule from Blocks is how a client resolves.

Configuring content

content is the content module, .deco/blocks.gen.ts, in almost every app (see The content module). It also accepts any object with a load() method that returns a snapshot, for cases the content module doesn't cover; the Loader interface is in Loaders.

Loaders

One interface. load() with no argument is production; load(pointer) is a draft. update() checks for newer content. Most sites never write one: they pass the content module. When and how to write one is in Write a loader. Here a loader is a source of content; it isn't the site editor's "loaders" or a router's route loader.

interface Loader {
  load(pointer?: string | null): Promise<Snapshot>;   // Snapshot = { revision, blocks }; see Types
  update?(): Promise<{ updated: boolean }>;
}
ContentLoadsUse it for
A snapshot { revision, blocks } (the content module)The content that ships with the app, generated by deco content. Ignores the pointer: there are no drafts in it. No update().Every site: the content your build ships. With the hosted Deco CMS, also the fallback before the first release
Any object with load()The snapshot its load() returns, or the draft a pointer names if it has drafts. If it can change while the process runs, give it update().Content kept somewhere other than the build, such as your own storage or Workers KV (see Example: Workers KV)
remoteLoader(fallback: Snapshot | Loader, { site?, token?, interval? })Hosted: releases and drafts from the Deco API, over the fallback. createCMS builds it for you when site and token are set. Without site or token, it's a loader over the fallback alone. A release or draft over 64 MB is refused while it downloads. See Publishing without a deploy.The hosted Deco CMS

Hosted remoteLoader uses complete snapshots for releases and private overlay assets for drafts. It captures the local production snapshot once, loads the overlay manifest and missing changed-block blobs, and exposes the combined view through the existing Snapshot interface. It does not request a matching base release. The draft client's revision is an opaque identity derived from the captured production revision and overlay version; cache composed results with both. Custom loaders may still return self-contained draft snapshots. See Draft overlays for fast previews.

Example: Workers KV

Large sites on Cloudflare Workers can keep their content out of the bundle (see Large content on Workers). The loader is a few lines of your own code:

src/kv-loader.ts
import type { Loader, Snapshot } from "@decocms/blocks";
 
// Loads the content module your deploy wrote to Workers KV under `key`.
export function kvLoader(kv: KVNamespace, key: string): Loader {
  return {
    async load() {
      const snapshot = await kv.get<Snapshot>(key, "json");
      if (!snapshot) throw new Error(`no content in KV under "${key}"`);
      return snapshot;
    },
  };
}
src/cms.ts
import { env } from "cloudflare:workers";
import { createCMS } from "@decocms/blocks";
import blocks from "../.deco";
import { kvLoader } from "./kv-loader";
 
// CONTENT is a KV binding; CONTENT_KEY is a variable your deploy sets, one key per deploy.
export const cms = createCMS({ blocks, content: kvLoader(env.CONTENT, env.CONTENT_KEY) });

Your deploy writes the content to that key before the new Worker takes traffic, for example with wrangler kv key put. Use a new key for every deploy, such as one named after the commit, so Workers still running the old code keep reading the old content during a rollout. It ignores the pointer, so it serves no drafts, and has no update(), because a deploy's key never changes.

A loader with update() is checked on its own: every interval (default DECO_CONTENT_INTERVAL or 60 000 ms, never below 60 000), at the next idle moment, so a request never waits on it. On Cloudflare Workers, which run no timers between requests, a due check runs after the response inside ctx.waitUntil. cms.update() checks at once, for a webhook or an admin "refresh now" button; it refreshes only the server that runs it, and never throws. A snapshot, like the content module, never changes, and a loader without update() is always current by construction.

If load(pointer) fails, or the pointer doesn't parse, every call on that client returns [null, error] with LOADER_FAILED: no silent fallback to published content, and never a mix of draft and published entries. Your app decides what to show.

A pointer comes from request input, so a loader that serves drafts must treat it as untrusted (see Write a loader).

Caching, ETags, and fallbacks live inside the CMS, which any number of clients share. The client only ever sees { revision, blocks }.

One instance per process

createCMS instances (and hosted remoteLoader instances) are process-wide singletons. Each is stored on globalThis under a Symbol.for("decocms.blocks…") key derived from its configuration: the content's identity (for the content module, the .deco folder it was generated from; for a loader you write, the loader object; never the revision, so a hot reload that hands in new content keeps the same instance), and, with the hosted Deco CMS, also the site ID and token. So a package loaded twice, by two bundles or by a dev reload (hot module replacement, HMR), still shares one content cache and one schedule of update() checks. Different configurations, such as several sites in one app, get different instances. Calling it again with the same key but different options keeps the first instance and logs a warning that names the conflicting options.

Each call returns a handle on that shared instance holding the call's own blocks: the content, caps, telemetry and update checks are shared, and each handle resolves with the block map it was created with. So a second bundle in the same process, such as Next.js's proxy.ts, can import your cms without replacing the app's block map, and a hot reload's new map is used by the cms it returns. Calling it again with the same block map object returns the same handle. Tests can start clean:

function resetForTests(): void;   // clears every stored instance

cms.settings()

Returns your site's CMS settings: the saved block named CMS, of the built-in type cms-settings, from the release, with every default filled in and the caps from createCMS applied. Telemetry, analytics and the draft helpers read the settings the same way.

interface CMS {
  settings(): Promise<EffectiveSettings>;
}
 
interface EffectiveSettings {
  preview: { hosts: string[] };     // the hosts previews are allowed on, within preview.hosts from code; ["*"] means every host
  telemetry: Required<Telemetry>;   // sample rates already capped by telemetry.limits
  analytics: Required<Analytics>;
}
  • Always the release. It reads the current release this server already has in memory, never a draft, so no draft can allow its own preview host or change what telemetry sends. It never fetches: at boot it reads the content module, and a newer release arrives with the next background check. With a loader you write that has no content in memory yet, it returns the defaults until the first release loads; after that, an update() keeps the previous release's settings until the next release loads.
  • Read-only. The settings are frozen, since every caller gets the same object; copy them to change anything.
  • Never rejects. A missing CMS block, a block of another type, or a section that fails to resolve gives that section's defaults (still capped by code).
  • Variants are picked per call. The block resolves on each call, so a field with variants is decided by its rules where you call it, in your request scope. Telemetry reads its section outside any request.

How each cap applies:

SectionWithout the block or fieldWith it
preview.hostspreview.hosts from code, or ["*"] when code sets noneThe entries that fall entirely within code's preview.hosts; the others are left out. An empty list allows no host.
telemetryenabled: true, metrics: true, errorSampleRate: 0.05, traceSampleRate: 0, then cappedEach sample rate is the lower of content's and telemetry.limits
analyticsenabled: true, collector: the hosted Deco CMS collectorAs saved; there's no cap

Host patterns

preview.hosts, in code and in content, is a list of host patterns. A request's host is the hostname and port of its URL (new URL(request.url)), so it's whatever your framework puts there, usually from the Host header.

PatternMatchesNever matches
"*"Every host
"staging.example.com"staging.example.com, on any portexample.com, www.staging.example.com
"*.example.com"a.example.com, a.b.example.com, on any portexample.com, badexample.com, a.example.com.attacker.com
"localhost:3000"localhost on port 3000 onlylocalhost, localhost:3001

The rule in full:

  • Compared in a normal form. Hostnames and patterns are compared lowercase, with one trailing dot removed, so Staging.Example.com. is staging.example.com. Names outside ASCII are compared in their punycode form (xn--…), which is how URLs carry them.
  • Exact names match only themselves. A pattern without * matches that one hostname.
  • A wildcard is a whole leading label. *.example.com matches a hostname that ends in .example.com and has at least one more label in front, compared label by label from the right. * can't appear anywhere else (a*.example.com, *.*.com), and at least two labels must follow it, so *.com isn't a pattern. "*" on its own matches every host.
  • Ports are optional. A pattern without a port matches any port. A pattern with one matches only a URL that names that port; URLs never name a default port, so write example.com, not example.com:443.
  • IP addresses match exactly. An IPv4 address, or an IPv6 address in brackets, can be a pattern, but never with a wildcard.
  • Anything else isn't a pattern, such as a scheme, a path, a space or an empty label. In code, createCMS throws; in content, the entry is left out.
  • An unreadable URL matches only "*". That includes a URL with a username or password (https://public.com@staging.example.com/, which a forged Host header can produce in a URL built from it) and a hostname with an empty label (a..example.com).

A content entry is within code's list when every host it matches is also matched by a code entry: staging.example.com is within *.example.com, *.a.example.com is within *.example.com, but *.example.com isn't within *.a.example.com. A content entry without a port is within only a code entry without a port, and "*" is within only "*". An entry outside code's list is left out, so content can narrow where previews are allowed but never widen it.

Draft pointers

A pointer is a string, <host[:port]><path[?query]>@<version>, that names where a draft lives and which version it is. forDraft passes it to your content source's load(pointer); the content module ignores it (see Releases and drafts). With the hosted Deco CMS, the site editor makes them (Previewing drafts). Two helpers cover everything in between:

interface DraftPointer {
  host: string;      // host[:port] of the content source that holds the draft (the Deco API, when hosted), e.g. "api.deco.example"
  path: string;      // starts with "/"; opaque to your app (hosted drafts put a token signed by the site editor in its query)
  version: string;   // opaque, immutable: the branch head or ETag
  variants?: { block: string; path: string; index: number }[];   // the variants a preview forces; absent when none
}
 
function parseDraftPointer(raw: string | null | undefined): DraftPointer | null;
function formatDraftPointer(pointer: DraftPointer): string;

parseDraftPointer is strict and returns null on anything unexpected: a scheme, a stray @, an unrooted path, an odd character in the host or version. It's what forDraft calls, so an app only needs it to look inside a pointer, for example to show which version is being previewed, to key a cache on version, or to reject a pointer before storing it in a cookie. formatDraftPointer is the inverse, for building one from parts, as a mobile app might from a deep link. It throws on parts that wouldn't parse back, such as a path with a space or a … in it.

__variant is a reserved parameter of the pointer's query: each one is a forced variant, <block>@<path>=<index> URL-encoded, where block is the saved block the multivariate is saved in, path its JSON path inside that block (dot-separated keys and array indexes, empty for the saved block itself) and index the variant to show. parseDraftPointer moves them out of path into variants and rejects the pointer if one is malformed; formatDraftPointer appends them. forDraft applies them to the content it reads, even from a source with no drafts, and hands load(pointer) the pointer without them, so every variant of one draft shares one load.

For websites, two more helpers carry a pointer from a URL into a cookie, so the parameter and cookie names never appear in app code. They're methods of the CMS because they check the request's host against your preview hosts. The hosted draft flow uses them; see Wire drafts into your app:

type RequestLike = Request | { url: string; headers: { get(name: string): string | null } };
 
interface CMS {
  draftPointer(request: RequestLike): Promise<string | null>;
  // ?__draft= from the URL first, then the deco-draft cookie. Null when neither is present, when the URL says ?__draft=off,
  // and on a host outside settings().preview.hosts, where the request gets the release.
 
  draftCookie(request: RequestLike): Promise<string | null>;
  // When the URL carries a valid ?__draft= on an allowed host, the Set-Cookie value that stores it
  // (HttpOnly; Secure; SameSite=None; Partitioned; Path=/). When it says ?__draft=off, one that expires the cookie,
  // on any host (how the site editor ends a preview). Null on every other request, and for a ?__draft= that doesn't parse.
}

Both take anything with a url and headers, so they work with a fetch Request, a Next.js NextRequest, a framework's request wrapper, or, in a Next.js Server Component, the result of headers() with the page's URL (see Next.js). The url must be absolute: a host that can't be read is treated as outside the list, unless every host is allowed.

On a host outside the list, the request gets published content, never an error. draftPointer returns null, ignoring both the parameter and the cookie, and draftCookie sets nothing. The check isn't access control, which is the signed, expiring grant inside the pointer (see Who may preview): it keeps drafts off your public domains, out of the caches in front of them and out of search engines. The list comes from cms.settings(), so it's always the release's, never the draft's.

forDraft itself checks no host, since a pointer doesn't always come from a request (see Not a website). If your app reads a pointer some other way, deciding where previews are allowed is up to that code.

The cookie is SameSite=None; Secure; Partitioned because the site editor shows your site in an iframe on another site, and a Lax cookie wouldn't be sent there, so the preview would fall back to the release. Partitioned keeps it inside the site editor's frame, so it doesn't follow you to normal visits, and HttpOnly keeps page scripts from reading it.

const pointer = parseDraftPointer(cookie);
if (pointer) console.log(`previewing ${pointer.version} from ${pointer.host}`);
 
// the path is opaque: copy it, don't build it (a custom loader's pointer; hosted
// pointers are delivery.decocms.com/sites/<site>/drafts?token=…@<overlay version>)
formatDraftPointer({ host: "api.deco.example", path: "/drafts/acme/main?token=abc123", version: "9f3c1a" });
// "api.deco.example/drafts/acme/main?token=abc123@9f3c1a"
 
// show variant 1 of the multivariate at sections.3 of the saved block Home
formatDraftPointer({ host: "localhost:4547", path: "/", version: "local", variants: [{ block: "Home", path: "sections.3", index: 1 }] });
// "localhost:4547/?__variant=Home%40sections.3%3D1@local"

client.resolve(target, options?)

Loads the content once and applies the lookup rule to the target: saved blocks expand, and each function the target names in your block map runs with its resolved inputs. Built-in blocks are always defined.

CallReturns
resolve("SummerSEO")The result of running the entry's function: for SummerSEO, seo({ title: "Sunny!", description: "Light layers for long days." })
resolve("SummerSEO", { run: false })The saved block, with references expanded and nothing run
resolve("SummerPage")The built-in page block's result: the page with seo and every block in sections resolved
resolve({ __resolveType: "seo", title: "Sale" })The result for an inline block: seo({ title: "Sale" })
resolve(page.sections)An array of results, one per block in the list; a block that resolves to undefined, such as a hidden one, is left out
resolve({ title: "Store" })The same object: it contains no blocks

A string target is always a saved block's name. Any other value is walked as is, so you can pass a page field without checking whether it's a block or a literal. T describes the result you expect, because a saved block's name can't carry a static type; it isn't validated at runtime.

Reading without running. With { run: false }, resolve stops after expanding saved blocks: references are replaced and merged, and no function runs. Use it for previews, tooling and debugging, and to get a page's blocks without running them, so you can resolve each one with its own call; the Next.js guide streams a page that way. client.list returns entries this way by default.

client.list(type, options?)

Every saved block whose __resolveType is type or an alias of it (another key for the same type, used when renaming). By default nothing runs: each entry comes back as resolve(name, { run: false }) would return it. With run: true each entry is resolved, so every block inside it, and the entry's own type, must be in the block map (built-ins included) or a saved block, or it fails with UNKNOWN_BLOCK. list("page", { run: true }) returns ready pages. Entries come back sorted by name, so the order is the same on every server.

interface ListOptions<T> {
  where?: (entry: T) => boolean;
  sort?: (a: T, b: T) => number;
  limit?: number;
  run?: boolean;
}
 
const today = new Date().toISOString().slice(0, 10);   // "YYYY-MM-DD", comparable as a string
const [posts] = await client.list<Post>("post", {
  where: (p) => p.date <= today,
  sort: (a, b) => b.date.localeCompare(a.date),
  limit: 20,
});

Filters run in memory over the loaded content map, the cost of content arriving as the whole map at once (see Snapshots and revisions). Sites with tens of thousands of entries of one type should keep an index elsewhere.

matchRoute(url, items)

A pure function. Given a URL and the entries you want to route, it returns the one that matches. The CMS doesn't know about URLs; this is the companion that does. See Pages and routing.

function matchRoute<T extends Route>(
  url: string | URL | Request,
  items: { routes: T[]; redirects?: Redirect[] },
): Match<T>;
 
type Match<T> =
  | { kind: "match";    entry: T; params: Record<string, string> }
  | { kind: "redirect"; location: string; status: 301 | 302 | 307 | 308 }
  | { kind: "not-found" };

Paths take literal segments, :name parameters and a trailing /* splat that matches one or more remaining segments into params["*"] (see Match order). Redirects win over routes, exact paths win over parameters, parameters win over a splat, and when two entries can match the same URL, the one earlier in the array wins. It never throws. Entries are compiled into a segment trie, cached per routes array object, so a lookup costs the URL's depth, not the number of routes, and passing the same array again skips the build. See how it matches.

createInstrumentedFetch(options)

From @decocms/blocks/fetch. The fetch every upstream client uses: it times each request until its response headers arrive and labels the measurement. The CMS in the same process sends those measurements wherever its telemetry option points; with no destination, nothing leaves your servers.

function createInstrumentedFetch(options: {
  provider: string;                                     // the provider label, e.g. "vtex", "acme-search"
  fetch?: typeof fetch;                                 // the fetch underneath; defaults to globalThis.fetch
  retry?: { attempts: number; backoffMs?: number };     // off unless set; a retried request is measured once
  circuitBreaker?: { failures: number; cooldownMs: number };   // off unless set
}): (input: string | URL | Request, init?: RequestInit & { operation?: string }) => Promise<Response>;

Each request is measured with provider, operation, status_class, cached and retries labels (see What's sent). A response with an x-cache: HIT header is labeled cached, so a cache you put underneath (see Upstream data) shows up in its measurements. It never logs request or response bodies, tokens or cookies. Writing a client with it is in Write a client.

Analytics

Page view settings are the analytics section of cms.settings(), the Analytics type in Types; see Analytics. From @decocms/blocks/analytics:

function AnalyticsScript(props: Analytics): ReactNode | null;   // the tracking script; render it in your root layout with (await cms.settings()).analytics; null when enabled is false
function track(name: string, props?: Record<string, string | number | boolean>): void;   // send your own event from the browser, through the script AnalyticsScript renders; does nothing without it

Secrets

From @decocms/blocks/secrets. Encrypts a value with your public key, for a script or an AI agent that writes content; the site editor does the same for editors. How secrets work is in Secrets.

function encryptSecret(publicKey: string, value: string): Promise<{ __resolveType: "secret"; ciphertext: string }>;
// publicKey: the contents of .deco/secrets.pub

Types

// A block as stored: the JSON in .deco/blocks, and what client.list and { run: false } return.
// Props never use it: a field's type is the value the function receives (see Schema).
type Block = { __resolveType: string; [input: string]: unknown };
 
// A block map: what .deco/index.ts exports and the CLI reads.
type BlockFunction = (inputs: any) => unknown | Promise<unknown>;
type Blocks = Record<string, BlockFunction>;
 
// One fixed copy of the content: what the content module exports and load() returns.
type Snapshot = {
  revision: string;
  blocks: Record<string, unknown>;     // the content map: entry name → saved JSON
  aliases?: Record<string, string>;    // the alias table deco content writes: old type name → your type
};
 
interface Loader {
  load(pointer?: string | null): Promise<Snapshot>;
  update?(): Promise<{ updated: boolean }>;
}
 
interface DraftPointer { host: string; path: string; version: string; variants?: { block: string; path: string; index: number }[] }
type RequestLike = Request | { url: string; headers: { get(name: string): string | null } };   // what draftPointer and draftCookie take
 
interface Route { name: string; path: string }                    // extend it to give a type a URL
 
// The built-in types. Their blocks are always in the registry and the schema: override one by declaring
// the key in your block map, e.g. page: (props: StorePage) => props with an interface that extends Page.
interface Seo { title: string; description: string }
interface Page extends Route { seo?: Seo; sections: ReactNode[] }   // resolved types (see Schema); without seo, your site's defaults apply
interface Redirect {
  from: string;
  to: string;
  permanent: boolean;                 // 301 or 302
  status?: 301 | 302 | 307 | 308;     // wins over permanent
  discardQueryParameters?: boolean;   // drop the request's query string instead of carrying it over
}
type Secret = string & { readonly __secret: true };   // a field the site editor saves encrypted, as a secret block; see /next/built-in-blocks#secrets
type Lazy<T> = () => Promise<T>;                    // a prop the built-in lazy block fills; resolves its value when called, at most once
interface Variant<T> { rule: boolean; value: Lazy<T> }   // one entry of variants: a rule and the variant it shows. Built-in multivariate takes { variants: Variant<T>[] }, runs the first variant whose rule is true, and returns Promise<T | undefined>
 
// The built-in cms-settings block's props: the type of the well-known saved block CMS (always in the schema).
// Every field is optional and any field can have variants. The block returns its input with the telemetry and analytics
// defaults filled in; read it through cms.settings(), which also applies code's caps. See /next/built-in-blocks#cms-settings.
interface CMSSettings {
  preview?: { hosts?: string[] };   // host patterns previews are allowed on, within createCMS preview.hosts; default: code's list, or every host
  telemetry?: Telemetry;
  analytics?: Analytics;
}
 
// What cms.settings() returns.
interface EffectiveSettings {
  preview: { hosts: string[] };     // ["*"] means every host
  telemetry: Required<Telemetry>;
  analytics: Required<Analytics>;
}
 
// The telemetry section. See /next/telemetry#telemetry-settings-are-content.
interface Telemetry {
  enabled?: boolean;          // default true; false switches telemetry off
  metrics?: boolean;          // default true
  errorSampleRate?: number;   // default 0.05, capped by telemetry.limits
  traceSampleRate?: number;   // default 0, capped by telemetry.limits
}
 
// The analytics section, and AnalyticsScript's props. See /next/analytics.
interface Analytics {
  collector?: string;   // an endpoint that accepts the One Dollar Stats format; default: the hosted Deco CMS collector
  enabled?: boolean;    // default true; false makes AnalyticsScript render nothing
}
 
type Match<T> =
  | { kind: "match";    entry: T; params: Record<string, string> }
  | { kind: "redirect"; location: string; status: 301 | 302 | 307 | 308 }
  | { kind: "not-found" };
 
type Result<T> = [T, null] | [null, CMSError];
 
interface CMSError {
  code: "NOT_FOUND" | "UNKNOWN_BLOCK" | "CYCLE" | "BLOCK_FAILED" | "LOADER_FAILED";
  message: string;
  path: (string | number)[];   // where in the tree it happened
  cause?: unknown;             // the original error, for BLOCK_FAILED and LOADER_FAILED
}

Errors

Nothing in the SDK throws at request time: resolve and list return [value, null] or [null, error], and matchRoute returns a Match. The one exception is client.revision(): it returns a plain promise, which rejects with a LOADER_FAILED error when the content can't load. Two entries that can match the same URL are a content bug deco check reports (see Backward compatibility). Check the error, not the value, because a block function can legitimately return null.

CodeWhen
NOT_FOUNDA string target names no saved block.
UNKNOWN_BLOCKA __resolveType names neither a type in the block map nor a saved block. Built-in blocks never cause it.
CYCLEReferences lead back to an entry that's already being expanded (see The rule in full).
BLOCK_FAILEDA block function threw. The original error is in cause.
LOADER_FAILEDThe loader couldn't load the content, or a draft pointer is invalid. The original error, if any, is in cause.

A CMSError has a code, a message, a path (where in the tree it happened, such as ["sections", 2, "product"]), and an optional cause. Log it on the server, and show visitors a generic message.