Docs

GraphQL in the SDK

client.graphql, the typed builder, typed documents and the tool spec for explorers and assistants.

View as Markdown

/api/graphql reads the same content as /api/entries, with the same key, version, limits and error codes. The schema is built for your key: it has exactly the models the key can read. The API reference is at capacms.com/docs/api/graphql.

In a Next.js server component

// app/blog/page.tsx
import { draftMode, headers } from "next/headers";
import { capaAttrs } from "@capacms/sdk/next";
import { graphql, tagsFor } from "@capacms/sdk/nextjs";
import { BlogIndexModels } from "./capa-graphql"; // written by capa-codegen --graphql

const BLOG_INDEX = `#graphql
  query BlogIndex($first: Int) {
    articles(first: $first, sort: [publishedAt_DESC]) {
      nodes { id model title author { name } }
    }
  }
`;

export default async function Blog() {
  const { data } = await graphql(BLOG_INDEX, { first: 5 }, {
    draftMode,
    headers,
    tags: tagsFor({ namespace: BlogIndexModels }), // articles, and authors for author { name }
    revalidate: 60,
  });
  return (
    <ul>
      {data?.articles?.nodes.map((a) => <li key={a.id} {...capaAttrs(a, "title")}>{a.title}</li>)}
    </ul>
  );
}

data and the variables are typed from the document itself, with no cast, once capa-codegen --graphql has read it (see Typed documents). Until then the call does not compile, and the error says to run it, so a document edited since the last run is never silently untyped.

Given tags or revalidate, graphql() keeps a published read in Next's data cache, through Next's own unstable_cache, under the tags you give. Given neither, it keeps nothing: the read is sent as a REST read is, and the page shows a publish on its next render. BlogIndexModels lists the models the query reads, which codegen writes beside its types: articles, and authors for author { name }. It changes when the query does, so a model the query starts reading is never left out. tagsFor({ namespace }) makes one tag per model (capa:model:articles), and capa:media. The webhook route above calls revalidateFromWebhook, which revalidates a model's tag whenever an entry of the model is published, unpublished or deleted, so the page shows the change on the next request. Editing a file in the media library, its alt text say, changes no entry, so it revalidates capa:media instead, and every read tagged by its models shows the new text. How long a read is kept:

  • With neither tags nor revalidate, not at all: every render reads the API, as entries.list does, so a site with no webhook route still shows a publish on the next request.
  • With tags and no revalidate, until one of its tags is revalidated. With the webhook route, that is until the next publish of a model it reads.
  • With revalidate: 60, also at most 60 seconds, so a missed webhook costs a minute of stale content at most.
  • With revalidate: 0, not at all.

A read with a revalidate and no tags is tagged capa:graphql (GRAPHQL_TAG), which revalidateFromWebhook revalidates on every content change, so it is never stale after a publish; tag it with its models to refresh the page only when one of them changes.

A read that answered with errors is never kept: a root field that timed out is shown once and read again on the next request. Next's fetch cache is not used for a GraphQL read, since it keeps every 200 and a GraphQL error is a 200, and in Next 15 it keeps nothing without a revalidate. A draft and an edit-mode page are read uncached. Outside a Next request, in a script or a test, the read is simply sent. unstable_cache is an option only to supply another implementation.

draftMode and headers work out draft and edit mode as getCapaClient does. Under draft mode the read uses CAPA_DRAFT_KEY, a cap_ key, and bypasses the cache. In edit mode (draft mode, or the editor's Published view) each entry that selected id and model is marked, so capaAttrs(node, field) makes it clickable in the Capa editor (see Live preview); a visitor's page carries no tags. Leave both out for a page with no preview.

graphql() reads CAPA_API_URL, CAPA_KEY and CAPA_API_VERSION like the other helpers, and a setting you pass in config (baseUrl, apiKey, version, fetch) is used instead of its variable, so a full config needs no env at all. A published read is a GET, which the CDN and the API also cache for the published key; a document too long for a URL goes as a POST, which only Next's data cache keeps (see How reads are sent and cached). The result's cacheTags holds the API's Surrogate-Key for a GET (m:<modelId>, e:<entryId>), for purging a CDN of your own. draft: true or false decides draft mode yourself. Pass persisted: true once your documents are stored with capa persist (see Persisted queries).

In a Node script

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

const capa = createClient({
  baseUrl: process.env.CAPA_API_URL!,
  apiKey: process.env.CAPA_KEY!,
  version: "2026-10-01",
});

const POPULAR = `#graphql
  query Popular { articles(first: 5, filter: { views: { gte: 10 } }) { nodes { id title } } }
`;

const { data, errors, extensions } = await capa.graphql(POPULAR);

for (const error of errors) console.warn(error.code, error.message, error.hint, error.path);
console.log(data?.articles?.nodes, extensions.cost?.actualQueryCost);

For a string codegen has not read, such as one built at run time, pass the data type yourself: capa.graphql<{ version: string }>("{ version }").

capa.graphql(document, variables?, options?) resolves once the API has run the document, even when errors is not empty: the root fields that worked still carry data, a root field that failed is null (which is why every list root is nullable in the schema and in generated types), and each error is a CapaGraphQLError.

A CapaGraphQLError is a plain object: the spec's message, locations, path and extensions, with code, hint, docs, param and type lifted out of extensions. So Response.json(result) in a route handler, and a server component passing errors to a client component, keep every field. isCapaGraphQLError(value) checks one by its shape, so it holds after JSON.

A request the API refuses as a whole throws CapaError, whose graphqlErrors holds every error the API sent: a document that does not parse or validate, a variable of the wrong type, a query over a budget, a filter the planner refuses. Its body has errors and no data. GraphQL over HTTP sends that refusal as a 200 on application/json, which this client asks for, and as a 4xx on application/graphql-response+json; either way the CapaError is the same, with status 400. Other refusals keep their own status: the key (401), the plan (402), the origin (403), the contract (404), a mutation (405) and the rate (429).

The API runs 4 reads at once per project, GraphQL documents and /api/entries reads counted together, and at most 3 of them for one client of a key, so one busy visitor never holds every slot. Up to 64 more per client and 256 per key wait for a slot. Past that it answers 429 with Retry-After. The client waits that long, plus up to 250 ms, and sends the request again, up to 3 times, so a page that reads many things at once from one key slows down instead of failing. retries: 0 throws the first 429 instead, and retries: n allows n repeats. A 429 that is thrown carries retryAfter, in seconds. A Retry-After over 30 seconds is thrown at once rather than waited out, and signal ends a wait early. entries.list and entries.get share these limits and throw their 429 with retryAfter rather than repeat it. When an API machine is full of other projects' reads, it answers 503 service_unavailable with Retry-After, which is thrown.

Where GraphQL is switched off, the CapaError says "This Capa deployment does not serve GraphQL." and its hint says to read with entries.list or entries.get meanwhile. Options: operationName, method, persisted: true, retries, and signal.

extensions.cost is the query's cost as Shopify's APIs report it, in entries. requestedQueryCost is the most the document can read: every list at its full first (25 at a root and 100 nested when you give none), capped at 5,000, plus 500 for each scan. actualQueryCost is what it did read, plus 500 for each scan that ran. The 5,000 limit is checked before the document runs, on a tighter figure: a nested list with no first counts 10 for each entry above it, not 100, so { articles(first: 1) { nodes { coauthors { nodes { name } } } } } passes as 11 entries and requests 101. That figure is budget.counted, and the limit is budget.limit, on every response: it is the number to watch, since a document is refused exactly when counted passes limit, and a refusal states it. There is no per-key budget, so there is no throttleStatus.

A document that uses a deprecated field, argument or enum value gets one { coordinate, reason } for each in extensions.deprecations, such as { "coordinate": "Articles._folder", "reason": "Read folder instead." }. capa-codegen --graphql warns about the same uses before you ship.

A scan reads entries the answer does not hold. Each of these is one:

  • a totalCount;
  • a filter or sort through a relation;
  • a root field that filters or sorts by its model's own fields, once however many such conditions it has, so articles(first: 10, filter: { featured: { eq: true } }) requests 510;
  • each contains, startsWith, endsWith or ne condition past the first;
  • a relation list sorted with sort.

Filters on id, createdAt, updatedAt, publishedAt and _tags, and a relation's eq, are served by an index and cost nothing. The API reference has every rule: capacms.com/docs/api/graphql#cost.

A development key's reads, such as the draft client's, also carry extensions.capa. Its cost breaks the cost down against every limit (depth, root fields, connections, nodes, fields) and counts the scans. Its rest names the REST request each root field was answered with. A production key's reads leave extensions.capa out, which keeps a cached page's answer small.

How reads are sent and cached

A read is a GET whenever its URL fits the API's limit of 8,192 bytes (path and query string), and a POST when it does not; a long document is never refused for its length. method: "POST" always sends a POST, and method: "GET" sends a GET that fits and a POST that does not.

RequestCached by the API and the CDN
GET with a production key, no errorsyes: public, max-age=60, purged by the entries it read (cacheTags)
GET with a development keyno (no-store): drafts move
any response with errorsno (no-store)
GET selecting me, __schema or __typeno (no-store)
POSTnever

So a document too long for a GET is not cached. Keep it cacheable with persisted queries: persisted: true sends only the hash by GET (see Persisted queries). graphqlSchema() reads the introspection by POST, since it is never cached. The admin host serves GraphQL by POST only and answers a GET as a path it does not serve; a read that did not ask for GET is then repeated as a POST, and that host is read by POST for five minutes. A method: "GET" read there throws "This Capa host does not serve GraphQL by GET." In Next.js, graphql() from /nextjs adds Next's data cache on top when a read gives tags or revalidate, for a published read with no errors, a POST included: kept under its tags until one is revalidated, or for revalidate seconds when you give it, and never for a draft.

The typed builder

Write the query as an object and get the result typed from exactly what you selected, without writing GraphQL text:

import { createClient } from "@capacms/sdk/next";
import type { CapaQuery } from "./capa-graphql"; // written by capa-codegen --graphql

const capa = createClient<CapaQuery>({ baseUrl, apiKey, version: "2026-10-01" });

const { data } = await capa.graphql.query({
  articles: {
    args: { first: 5, sort: ["publishedAt_DESC"], filter: { featured: { eq: true } } },
    nodes: { title: true, author: { name: true } },
  },
});

data?.articles?.nodes[0].author?.name; // string | null
data?.articles?.nodes[0].body;         // compile error: not selected

true selects a scalar, an object selects fields of a relation or a connection, and args sits beside the fields of anything that takes arguments. A misspelled field, argument, filter field or operator fails to compile, even beside a correct one, and so does an argument of the wrong type, a sort value that is not in the enum, or a required argument left out (article without args: { id }). The compiler's error names the key and where it was written: titel: true under articles.nodes fails with Type 'true' is not assignable to type 'true & SelectionError<"titel is not a field of articles.nodes">'. A Date in args is sent as its ISO text. Without CapaQuery the builder still runs, untyped. selectionToDocument(selection) returns the text it sends.

query() takes the options capa.graphql takes, except persisted. Its document is printed when it runs, so capa persist never stored it, and it is already a GET that the API and the CDN cache. To persist a read, write it as a #graphql literal (see Persisted queries).

In a Next.js server component, read through getCapaClient. A read that names tags is kept in Next's data cache under them:

// app/blog/page.tsx
import { draftMode, headers } from "next/headers";
import { getCapaClient, tagsFor } from "@capacms/sdk/nextjs";
import type { CapaQuery } from "./capa-graphql";

export default async function Blog() {
  const capa = await getCapaClient<CapaQuery>({ draftMode, headers });
  const { data } = await capa.graphql.query(
    { articles: { args: { first: 5, sort: ["publishedAt_DESC"] }, nodes: { id: true, title: true } } },
    { tags: tagsFor({ namespace: "articles" }), revalidate: 60 },
  );
  return <ul>{data?.articles?.nodes.map((a) => <li key={a.id}>{a.title}</li>)}</ul>;
}

tags and revalidate work as they do for graphql() (see In a Next.js server component). A read is kept until a publish of a model its tags name, a read with errors is never kept, and a draft or an edit-mode page is read uncached. A read with neither is not kept, as the client's REST reads are not. capa.graphql(document) on the same client takes them too.

Read one field twice in one request with an alias: any other key, with __aliasFor naming the field it reads. A home page's featured and latest articles are one request:

const { data: home } = await capa.graphql.query({
  articles: { args: { first: 3, filter: { featured: { eq: true } } }, nodes: { id: true, title: true } },
  latest: { __aliasFor: "articles", args: { first: 5, sort: ["publishedAt_DESC"] }, nodes: { id: true, title: true } },
});

home?.latest?.nodes[0].title; // string | null, typed as articles is

The alias is checked as the field it names, arguments and fields included, and sent as latest: articles(...). A scalar is aliased with __aliasFor alone: { headline: { __aliasFor: "title" } }.

Typing a component's props

NodeOf names one entry of a builder read, so a component that renders it takes exactly what the selection reads, and the two cannot drift:

import type { NodeOf } from "@capacms/sdk/next";
import type { CapaQuery } from "./capa-graphql";

const teasers = {
  articles: { args: { first: 5 }, nodes: { id: true, title: true, author: { name: true } } },
} as const;

// { id: string; title: string | null; author: { name: string | null } | null }
type Teaser = NodeOf<CapaQuery, typeof teasers, "articles">;

function Card({ article }: { article: Teaser }) {
  return <li>{article.title} by {article.author?.name}</li>;
}

const { data } = await capa.graphql.query(teasers);
const cards = data?.articles?.nodes.map((article) => <Card key={article.id} article={article} />);

It is a node of a list root, the entry of a single root (article), or the node of an alias (NodeOf<CapaQuery, typeof home, "latest">). QueryResult<CapaQuery, typeof teasers> is the type of data as a whole.

The same data in REST's shape

The builder returns GraphQL's shape, because that is what its types describe: articles.nodes[0].author.name. Code written against /api/entries reads entries instead: data[0].fields.author.fields.name. toTree converts one to the other:

import { toTree } from "@capacms/sdk/next";
import { capaTreeLayout } from "./capa-graphql"; // written by capa-codegen --graphql

const selection = {
  articles: {
    args: { first: 5, sort: ["publishedAt_DESC"] },
    nodes: { id: true, status: true, title: true, author: { id: true, status: true, name: true } },
  },
} as const;
const { data } = await capa.graphql.query(selection);
const { articles } = toTree(data, selection, capaTreeLayout);
articles?.map((entry) => entry.fields.title); // each a string | null, as entries.list types it
// deep-equal to (await capa.entries.list("articles", { select: "title,author(name)", sort: ["-publishedAt"], limit: 5 })).data

capaTreeLayout is what toTree reads of your schema (each model's root fields, and which fields are renamed, relations, ids or media), written by codegen beside the types, so the conversion reads nothing from the API. Without codegen, pass the key's schema instead. That is one introspection request, so read it once and reuse it:

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

const schema = await capa.graphqlSchema(); // one request: read it once, reuse it
const selection = { articles: { args: { first: 5 }, nodes: { id: true, status: true, title: true } } } as const;
const { data } = await capa.graphql.query(selection);
const { articles } = toTree(data, selection, schema); // the same tree as with capaTreeLayout

A selection kept in a variable is declared as const, so each true and each sort value keeps its exact type and the result is typed exactly.

Each model root field becomes its REST data, typed from the selection: a list root as an array of entries, a single root as one entry or null, and null for a root that failed (its error is in errors). System fields sit beside fields (_version as version), every other field sits under fields by its namespace (hero_image as hero-image), every entry carries its model from the schema, whether you selected it or not, a relation list becomes { items, pageInfo } ({ items } when you did not select its pageInfo), a relation into a model the key cannot read is { id, model } as REST writes it unexpanded (a list of them is { items, pageInfo }), and a media value keeps REST's public shape and key order, { id, url, alt, type, width, height }, as far as you selected it (a list of media is an array of those).

The result is deep-equal to the REST response for the same read when the selection names the rest of what REST always returns: id and status on every entry, pageInfo { hasNextPage endCursor } on each relation list, and all six media fields. Its type is then assignable to the entry entries.list<Articles, typeof select> returns for the same read, so a function written for the REST read takes it unchanged. With a typed client, a selection without id and status does not compile. Three things differ by design, because GraphQL does not carry what REST says there:

  • A missing single relation (deleted, or a draft the key cannot see) is null in GraphQL and { id, model, missing: true } in REST.
  • A missing item of a relation list is left out in GraphQL, so items is shorter. REST keeps its slot as { id, model, missing: true }.
  • A value that does not fit its field's type is null in GraphQL and the raw value in REST.

Page a list with the GraphQL result's own pageInfo; version, me and entry are not model roots, and toTree refuses them. A root alias (latest: { __aliasFor: "articles", ... }) becomes the REST data of the field it names, under its own key. An alias below a root has no REST equivalent, since REST reads each field once, so toTree refuses it too.

Typed documents

{
  "scripts": {
    "dev": "capa-codegen --graphql --watch --out src/capa-graphql.ts & next dev",
    "prebuild": "capa-codegen --graphql --out src/capa-graphql.ts"
  }
}

capa-codegen reads CAPA_API_URL and CAPA_KEY from your shell, else from .env.local and .env as next dev reads them, so a Next.js site needs no extra setup. --watch keeps running and writes the module again whenever a document changes, and when the key's schema does (it reads the schema again every minute). A document with a problem is printed with its file and line, and the last good module stays in place.

Write a document where you use it, marked in one of three ways, and codegen types it by its text:

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

const LATEST = `#graphql
  query Latest($first: Int) { articles(first: $first) { nodes { id title } } }
`;
const ONE = /* capa */ `query One($id: ID!) { article(id: $id) { title views } }`;
const VERSION = gql(`query Version { version }`);

const { data } = await capa.graphql(LATEST, { first: 10 });
data?.articles?.nodes[0].title;             // string | null
await capa.graphql(LATEST, { first: "10" }); // compile error
await capa.graphql(ONE);                     // compile error: id is required

The generated file adds each literal's text to CapaDocuments with declare module "@capacms/sdk/next", and client.graphql and graphql() from /nextjs look the text up, so nothing is imported from it. Keep the generated file inside your tsconfig's include. A literal is sent exactly as written, so it holds one operation and every fragment it spreads, written inside it or spread in with ${} (see Fragments); codegen says so, with the file and line, when it does not. A literal of fragments only is a fragment source for others.

Codegen also reads every .graphql file and every gql... tagged template under src (or --documents dir1,dir2). A tagged template is a plain string to TypeScript, so for those, and for .graphql files, import the <Name>Document codegen writes for every named operation:

// src/queries/articles.graphql
// query ArticlesPage($first: Int) { articles(first: $first) { nodes { id title } } }

import { ArticlesPageDocument } from "./capa-graphql";

const { data } = await capa.graphql(ArticlesPageDocument, { first: 10 });
data?.articles?.nodes[0].title;                            // string | null
await capa.graphql(ArticlesPageDocument, { first: "10" }); // compile error

Fragments

import type { ArticleTeaserFragment } from "./capa-graphql";

const ARTICLE_TEASER = `#graphql
  fragment ArticleTeaser on Articles { id title }
`;

const TEASERS = `#graphql
  query Teasers($first: Int) { articles(first: $first) { nodes { ...ArticleTeaser } } }
  ${ARTICLE_TEASER}
` as const;

function teaser(props: ArticleTeaserFragment) {
  return props.title;
}

const { data } = await capa.graphql(TEASERS, { first: 3 });
data?.articles?.nodes.map(teaser);

Write a fragment in a #graphql literal of its own and spread it into a query with ${NAME}, as Hydrogen does. Codegen reads the query with the fragment's text in place, which is exactly the text the program sends, so the query is typed and persisted like any other literal. End the query's template with as const: TypeScript keeps the text of a template with ${} only then, and codegen says so when it is missing. A ${} that names anything else is text only known at run time, which codegen skips and names.

Codegen writes a <Name>Fragment type for every fragment, from a literal or a .graphql file, for a component that takes the fragment's data as props: the nodes of any query that spreads the fragment fit it.

The generated file also holds the schema's types, CapaQuery for the typed builder, capaTreeLayout for toTree, and <Name>Models beside each operation: the models it reads, for its cache tags (see In a Next.js server component). That is each model whose entries it selects, and each model a filter or sort reaches through a relation. A filter or sort passed as a variable counts every model its type can reach, and entry(id:) counts every model, since either can read any of them.

Codegen names, with its file and line, every template it does not read that looks like a query, and says what to change: one left unmarked, graphql... (from /nextjs that is a request, not a tag), and one with a ${} that names no literal of the project, whose text is only known at run time.

A field the key cannot read is a codegen error with its file and line, so a model change fails CI instead of production. A field, argument, filter operator or sort value a later Capa-Version phases out is @deprecated in the generated types, so your editor strikes it through, and codegen prints each use with its file, line and the reason, while still writing the module. --check exits 2 when the committed file is stale. --save-schema capa-schema.json writes the schema it read, and --schema capa-schema.json reads it back instead of calling the API, for CI without a key. Codegen needs graphql installed in your project (pnpm add -D graphql); nothing else in the SDK does.

A literal codegen has not read yet, new or edited since the last run, does not compile: the error says run capa-codegen --graphql. Text built at run time is a plain string and stays untyped, and so does a call that names its data type, capa.graphql<{ version: string }>(text).

A REST select as a builder read, and back

selectToSelection turns a REST read into a typed builder selection, which client.graphql.query runs and toTree turns into that read's data. graphqlToSelect gives a builder selection's REST request, as it gives a tool spec's:

import { graphqlToSelect, selectToSelection, toTree } from "@capacms/sdk/next";

const schema = await capa.graphqlSchema();
const selection = selectToSelection(schema, "articles", "title,author(name)", { sort: ["-views"], limit: 5 });
const { data } = await capa.graphql.query(selection);
const { articles } = toTree(data, selection, schema);
// deep-equal to (await capa.entries.list("articles", { select: "title,author(name)", sort: ["-views"], limit: 5 })).data

graphqlToSelect(schema, { articles: { args: { first: 5 }, nodes: { title: true, author: { name: true } } } }).url;
// "/api/entries/articles?select=title,author(name)&limit=5"

The selection names what toTree needs to answer what REST answers: id, model and status on every entry, pageInfo on every relation list, and all six media fields. Its fields are known only when it runs, so its data and its tree are untyped, with a typed client too. It takes what selectToGraphQL takes and refuses what it refuses. A relation the select names bare, which REST returns unexpanded as { id, model }, is read expanded to its id, the REST read author(id), because GraphQL reads a relation to a model the key can read as an entry. graphqlToSelect reads a selection's one model root field, and throws CapaBuildError for any other root (version, entry), and for a relation list read from a cursor in a list read, which have no REST request here. last with no before, or with before: null as Relay and Apollo send it, reads the end of the list: REST's before=end.

The tool spec: build a query for an explorer or an assistant

Tools that build queries (the admin's Explorer, the MCP server) share one plain format, the tool spec: { model, fields, first, sort, filter }. The SDK reads the key's schema once and writes GraphQL from it in Capa's canonical format, and moves between it and a REST read:

import { buildGraphQLQuery, graphqlToSelect, selectToGraphQL } from "@capacms/sdk/next";

const schema = await capa.graphqlSchema(); // models, fields, filters, sorts, from one request

const spec = { model: "articles", fields: ["title", { field: "author", fields: ["name"] }], sort: ["views_DESC"], first: 5 };
const { query, variables } = buildGraphQLQuery(schema, spec);
graphqlToSelect(schema, spec).url;
// "/api/entries/articles?select=title,author(name)&sort=-views&limit=5"

selectToGraphQL(schema, "articles", "title,author(name)", { sort: ["-views"], limit: 5 });
// the same tool spec, from a REST read

The tool spec is not the typed builder's selection: client.graphql.query and toTree take the selection, which selectToSelection writes.

graphqlToSelect writes the REST twin exactly as the API writes it in extensions.capa.rest: the filter as where JSON, the root's default page size left out, a relation list's first written as limit: whenever you give it (100 included, since REST counts a limit you write in full and one you leave out as 10), and a system key a field of the model shadows written with $ (_tags beside a field called tags is select=$tags,tags; createdAt_DESC beside a field called createdAt is sort=-$createdAt). That includes a field GraphQL leaves out because its name collides with another's, which the type's description lists as Not exposed: and the schema summary as notExposed: beside a hidden field called id, id_DESC is sort=-$id. selectToGraphQL reads $ names back, and refuses a REST name for a hidden field, which GraphQL cannot read. It selects what the REST read returns: a media value with all six of its fields (id url alt type width height), and *, or no select, as everything: the system keys createdAt, updatedAt, publishedAt, version, folder and tags, every field, and each relation list as a connection of ids (coauthors { nodes { id } }), the page of references REST returns. A reference to an entry the key cannot see is the one difference left: REST shows it as { id, model, missing: true }, and GraphQL reads it as null, or leaves it out of a list. buildGraphQLQuery with no fields keeps its own shorter default, media as id url alt. Both helpers send each filter value as the type its filter input declares, since GraphQL refuses any other: "10" for a number field becomes 10, and has: "true" on a list of true/false values becomes true, which its BooleanListFilter takes. A name the schema does not have, or a key a relation's spec does not take (frist for first, at any depth), throws CapaBuildError with didYouMean, before anything is sent. A where on $tags ports to GraphQL's _tags filter:

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

const schema = await capa.graphqlSchema();
selectToGraphQL(schema, "articles", "title", { where: { $tags: { has: "news" } } });
// { model: "articles", fields: ["title"], filter: { _tags: { has: "news" } } }

A model GraphQL leaves out, because its type name would collide with another's (twin_a and twin-a), is listed in the summary's restOnly. Asked for one, the builder throws CapaBuildError naming its REST read (twin_a is readable over REST only: GET /api/entries/twin_a.) and why, with no didYouMean: entries.list("twin_a") reads it.

Two REST reads have no GraphQL twin and throw too: a where on $version, $folder, $model or $status (GraphQL filters id, createdAt, updatedAt, publishedAt and _tags), and a filter hop through a relation the key cannot read (REST answers it unknown_field).

A tool spec pages as a REST read does: first with after reads the page after a cursor, and first with before the entries just before one. GraphQL writes the second as last with before, and the API refuses first there, so { first: 25, before } prints articles(last: $last, before: $before), and a tool spec with both after and before throws, as the API refuses it. before: "end" reads the last entries of the list, REST's before=end: { first: 5, before: "end" } prints articles(last: $last), and the list's startCursor pages back from there. after: "end" throws, since end is not a cursor. A read of one entry also pages a relation list from its cursor, as REST's coauthors(name,after:…) does: { field: "coauthors", fields: ["name"], after } in a spec with mode: "single", or args: { after } on the list in a selection. A list read throws for it, as the API refuses it there (after: applies to a single entry). sort takes one value or a list, at the root as on a relation list.

A spec written as REST writes a read throws, and says what to write instead: "author.name" or "author(name)" in fields is { field: "author", fields: ["name"] }, "*" is fields left out, and a sort "-publishedAt" is "publishedAt_DESC" (in didYouMean too). Relations nest up to 4 deep below the root entry, which with the root is the API's 5 levels of entries; a fifth throws CapaBuildError and names the field to select without fields, for its id.