The /api/ client
createClient from @capacms/sdk/next: entries, typed reads, the flat shape, inflate and errors.
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 authortells 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
nullwhen it was never filled, as the API writes it.title?: stringin the model reads asstring | 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 Authorincluded 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,$createdAtand the other$names in the select are the system keys, as the API reads them, soselect=$tags,tagson a model with its owntagsfield 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:
arelated tobrelated toais 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
limitorsort, the first one wins, andinflatecannot tell them apart. Every other request round-trips exactly. - In edit mode, included entries are marked, and so is every copy
inflatemakes 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".