Docs

The /api/ client

createClient from @capacms/sdk/next: entries, typed reads, the flat shape, inflate and errors.

View as Markdown

Create one client per key and pin the platform version in code:

import { createClient, CapaError } from "@capacms/sdk/next";

const capa = createClient({
  baseUrl: process.env.CAPA_API_URL!,
  apiKey: process.env.CAPA_KEY!,           // cap_live_..., cap_test_..., or the legacy key your site has
  version: "2026-10-01",
  contract: 1,
});

The /api/ client sends x-api-key, Capa-Version, optional Capa-Contract, and Accept: application/json. It never sends X-Tenant-Key; the tenant comes from the key.

Entries

const page = await capa.entries.list("articles", {
  select: [
    "title",
    "views",
    { author: ["name"] },
    { coauthors: { select: ["name"], limit: 5, sort: "-name" } },
  ],
  filter: {
    views: { gte: 10 },
    "author.name": { eq: "Ada Vale" },
  },
  sort: ["-views"],
  limit: 25,
  count: true,
});

for await (const entry of capa.entries.iterate("articles", { select: ["title"] })) {
  console.log(entry.fields.title);
}

const one = await capa.entries.get("articles", "entry-id", {
  select: ["title", { author: "*" }],
});

select may be the grammar string the API reference describes (capacms.com/docs/api/entries) or the object form above. Lists return { data, page, meta, cacheTags }; singles return { data, meta, cacheTags }. cacheTags is parsed from the Surrogate-Key header. get() returns null for 404 entry_not_found and throws every other error.

Filters use the /api/ operators:

await capa.entries.list("articles", {
  filter: {
    id: { in: ["00000000-0000-4000-8000-000000000021", "00000000-0000-4000-8000-000000000022"] },
    views: { gte: 10 },
    tags: { hasAny: ["news", "launch"] },
  },
  where: { or: [{ featured: { eq: true } }, { views: { gt: 100 } }] },
});

Unknown filter operators throw a local TypeError before any request is sent. Per-call { signal } is forwarded to fetch.

Typed reads

capa-codegen writes an interface per model (see Codegen). Pass it, and the select's own type, and fields is typed as the API returns it:

import type { Articles, ArticlesSelect } from "./capa-types"; // written by capa-codegen

const select = [
  "title",
  { author: ["name"] },
  { coauthors: { select: ["name"], limit: 3 } },
] as const satisfies ArticlesSelect;

const typed = await capa.entries.list<Articles, typeof select>("articles", { select });

for (const article of typed.data) {
  const { title, author, coauthors } = article.fields;
  if (author && "fields" in author) console.log(title, author.fields.name);
  for (const coauthor of coauthors.items) {
    if ("fields" in coauthor) console.log(coauthor.fields.name);
  }
  article.fields.body; // compile error: the select does not name it
}
  • A relation the select expands is the related entry, with its own fields, or { id, model, missing: true } when that entry was deleted, is unpublished for a production key, or is in a model the key cannot read. "fields" in author tells them apart.
  • A relation the select names without expanding it ("author", or every relation under *) is a reference, { id, model }.
  • A relation list is { items, pageInfo }, expanded or not.
  • Media is { id, url, alt, type, width, height }.
  • A field the select names is always there, and null when it was never filled, as the API writes it. title?: string in the model reads as string | null.
  • A field the select does not name is not there, so reading it does not compile.

Without typeof select, or with a select written as a string, every field is typed, and each relation as whichever of the three it may be. get and iterate take the same two types, and EntryFields<Articles, typeof select> names the type of fields.

Flat responses: each related entry once

By default an expanded relation is nested where you selected it, so twenty articles by one author carry that author twenty times. Pass shape: "flat" and every relation comes back as a { id, model } reference, with each expanded entry once in included, keyed by model namespace and then id:

const select = ["title", { author: ["name"] }] as const satisfies Select<Article>;
const flat = await capa.entries.list<Article, typeof select>("articles", {
  select,
  shape: "flat",
});

flat.data[0].fields.author;             // { id: "…", model: "authors" }
flat.included.authors[authorId].fields; // { name: "Ada Vale" }, typed from Author

included is typed by the select: the union of the entry types it expands, at any depth, each field of which may be absent, since an entry holds what every path that reached it selected. A relation in data or in included is a reference. A select written as a plain string types included as Record<string, unknown>. get takes shape: "flat" the same way. iterate reads the tree shape only.

inflate turns a flat result back into the tree result, deep-equal to what the same request without shape returns:

import { inflate } from "@capacms/sdk/next";

const tree = inflate(flat); // { data, page, meta, cacheTags }, typed as the tree read
  • It walks the select the request sent. A result from this client carries it (flat.select); for a body you fetched yourself, pass it: inflate(body, "title,author(name)").
  • $tags, $createdAt and the other $ names in the select are the system keys, as the API reads them, so select=$tags,tags on a model with its own tags field comes back with both.
  • It returns copies. The same author under twenty articles is twenty equal, independent objects, as when a tree body is parsed. The input is not changed.
  • Cycles end where the select ends: a related to b related to a is inflated to the depth you wrote and no further.
  • An entry reached by two paths holds the union of what they selected, and one value per field. If two paths expand the same array relation with different limit or sort, the first one wins, and inflate cannot tell them apart. Every other request round-trips exactly.
  • In edit mode, included entries are marked, and so is every copy inflate makes of them.

Errors

try {
  await capa.entries.list("articles", { limit: 500 });
} catch (error) {
  if (error instanceof CapaError) {
    console.log(error.status, error.code, error.param, error.hint, error.requestId);
  }
}

CapaError carries { status, type, code, message, param, hint, requestId, docs }. A non-JSON response is reported as code: "unparseable_response".