Docs
Developer guides

TypeScript

Generate types from your models with capa-codegen, then get reads typed from exactly what they select.

View as Markdown

@capacms/sdk ships two commands. capa-codegen writes TypeScript types from your project's models. capa-codegen --graphql also types every GraphQL document in your code. After that, a read is typed from what it selects, and a misspelled field fails to compile.

The examples use an articles model with title, body, views, featured, tags, author and coauthors, and an authors model with name and bio.

Install

pnpm add @capacms/sdk@next
pnpm add -D graphql

The SDK runs on Node 18 or later. Its types need TypeScript 5.0 or later. graphql is only needed by the codegen.

Set the two variables every Capa tool reads, in your shell or in .env.local:

CAPA_API_URL=https://cdn.capacms.com
CAPA_KEY=cap_live_...

capa-codegen reads them from the shell first, then from .env.local and .env, as next dev does.

Generate model types

pnpm capa-codegen --out src/capa-types.ts

You get one interface per model the key can read, and a <Model>Select type beside each:

export interface Articles {
  title?: string;
  body?: string;
  views?: number;
  featured?: boolean;
  tags?: string[];
  author?: CapaRelation<Authors>;
  coauthors?: CapaRelationList<Authors>;
}

export type ArticlesSelect = import("@capacms/sdk/next").Select<Articles>;

The interfaces are read from the key's schema, so a key limited to one model writes one interface. Every field is optional, and an options field is typed string.

Commit the file. Generating at install time needs credentials in CI and Docker builds. A committed file turns "a model changed" into a reviewable diff.

Type a REST read

Pass the model interface and the select's own type. fields is then typed exactly as the API returns it:

import { createClient } from "@capacms/sdk/next";
import type { Articles, ArticlesSelect } from "./capa-types";

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

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

const popular = await capa.entries.list<Articles, typeof select>("articles", {
  select,
  sort: ["-views"],
  limit: 10,
});

for (const article of popular.data) {
  const { title, views, author, coauthors } = article.fields;
  if (author && "fields" in author) console.log(title, views, author.fields.name);
  for (const coauthor of coauthors.items) {
    if ("fields" in coauthor) console.log("  with", coauthor.fields.name);
  }
}

What the types say:

  • A field the select names is always present, and null when it was never filled.
  • A field the select does not name is not there. Reading article.fields.body above does not compile.
  • An expanded relation is the related entry with its own fields, or { id, model, missing: true } when that entry is gone or unpublished. "fields" in author tells them apart.
  • A relation you name without expanding is a reference, { id, model }.
  • A relation list is { items, pageInfo }.

A misspelled field in select fails to compile, because of satisfies ArticlesSelect.

Type GraphQL documents

Write a document where you use it, marked as #graphql:

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

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

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

const { data, errors } = await capa.graphql(LATEST, { first: 5 });
for (const error of errors) console.warn(error.code, error.message);
for (const article of data?.articles?.nodes ?? []) {
  console.log(article.title, article.author?.name);
}

Then generate the module that types it:

pnpm capa-codegen --graphql --out src/capa-graphql.ts

Now data and the variables are typed from the document itself. capa.graphql(LATEST, { first: "5" }) fails to compile.

A #graphql literal that codegen has not read yet does not compile either. The error says to run capa-codegen --graphql, so a document you edited is never silently untyped.

Keep both commands in package.json:

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

--watch rewrites the module when a document changes, and when the key's schema does. It checks the schema once a minute.

Codegen also reads .graphql files and gql tagged templates under src. For those, import the <Name>Document it writes for every named operation.

Build queries as objects

Pass CapaQuery from the generated module and write the query as an object. The result is typed from exactly what you select:

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

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

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

console.log(data?.articles?.nodes[0]?.author?.name);

true selects a field. An object selects inside a relation. args sits beside the fields of anything that takes arguments. A misspelled field, filter or sort value fails to compile, and the error names it.

Type a component's props

NodeOf names one entry of a builder read. A component that takes it cannot drift from the query:

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;

type Teaser = NodeOf<CapaQuery, typeof teasers, "articles">;

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

In CI

Fail the build when the committed types are stale:

pnpm capa-codegen --graphql --check --out src/capa-graphql.ts
Exit codeMeaning
0Written, or already current.
1Failed: a document has a problem, or the API could not be read.
2--check found the committed file out of date.

No key in CI? Save the schema where you have one, commit it, and read it back:

pnpm capa-codegen --graphql --save-schema capa-schema.json --out src/capa-graphql.ts
pnpm capa-codegen --graphql --schema capa-schema.json --check --out src/capa-graphql.ts

A field the key cannot read is a codegen error with its file and line, so a model change fails CI instead of production.