Docs

SDK changelog

Every release of @capacms/sdk, newest first.

View as Markdown

The SDK reference covers the current release. Install it with pnpm add @capacms/sdk@next.

1.0.0-next.8 (2026-10-02)

  • 1.0.0-next.8. Preview on a live site, in draft mode on its production domain. preview() takes the legacy key a site already holds (pk_, sk_ or unprefixed), as the API does: Capa checks the token with any key that reads the project, and refuses a token made for another project. It used to throw a TypeError for a legacy key before any request. Draft reads through the SDK still take a cap_ key only, and the legacy-key warning says so.
  • Next.js: createPreviewRoute and exitPreviewRoute take Next's cookies. Given it, the draft cookie is re-set HttpOnly; Secure; SameSite=None; Partitioned; Path=/; Max-Age=3600 (maxAge changes the hour), so Safari 26.2 and later send it inside the editor's frame and it no longer lasts until the browser closes. Exit and a bad link delete it partitioned, which is the only deletion that reaches it. redirect is optional now: left out, each route answers with its own 307, marked X-Robots-Tag: noindex, nofollow, Referrer-Policy: no-referrer and Cache-Control: private, no-store, and the preview route's carries the editor's frame-ancestors. Given redirect, a route calls it as before.
  • Next.js: capaHeaders({ adminOrigins }) returns rules for next.config's headers() that send X-Robots-Tag: noindex, nofollow and Content-Security-Policy: frame-ancestors 'self' https://app.capacms.com on a request carrying the draft cookie or a capa-preview, capa-edit or capa-view query, and on nothing else, so a visitor's response is unchanged. Also exported: draftHeaders, frameAncestors (it throws for a value that is not a bare origin), frameDraftCookie, clearDraftCookie, CAPA_ADMIN_ORIGIN, DRAFT_ROBOTS_TAG, DRAFT_COOKIE_MAX_AGE, PREVIEW_PARAM and VIEW_PARAM.
  • Next.js: capaMiddleware marks every edit-mode response, and every request carrying capa-edit or capa-view, X-Robots-Tag: noindex, nofollow, and resolveEditRequest returns that value as robotsTag.
  • Docs: "Add preview to a live site without changing it", the steps for a site that keeps its own Capa reads, and why editMode() and getCapaClient() make a static page dynamic.

1.0.0-next.7 (2026-09-29)

  • 1.0.0-next.7. Browsers: the /api/ and legacy clients called the platform fetch as a method of their config, which a browser refuses ("Failed to execute 'fetch' on 'Window': Illegal invocation"), so every read from a browser failed unless fetch was passed in. The default fetch is now called through globalThis on each request, which also picks up a fetch a framework patches in later.
  • next is no longer a peer dependency. No range matches every Next canary, so a site on next@16.3.0-canary.39 could not npm install the SDK without --legacy-peer-deps. @capacms/sdk/nextjs/overlay still imports next, which only a Next site loads.
  • Docs: the edit mark is dropped wherever an entry is serialized to the browser (Pages Router props, SvelteKit and Remix loaders, Nuxt payload); pass the edit flag to capaAttrs there. Found testing preview on nine stacks.
  • Next.js: a GraphQL read that gives neither tags nor revalidate, through getCapaClient().graphql(), its builder or graphql(), is no longer kept in Next's data cache. It is sent as entries.list and entries.get send theirs, with no cache and no next, so a publish shows on the next render whether the page reads by REST or by GraphQL. It used to be kept under capa:graphql until a webhook revalidated it, so a site with no webhook route kept serving the old answer by GraphQL while REST showed the new one. A read that gives tags or a revalidate is kept as before. To keep a read with no tags as it was kept, pass revalidate: false, which keeps it under capa:graphql until the next publish. tags: [] now counts as no tags.

1.0.0-next.6 (2026-09-28)

  • 1.0.0-next.6. GraphQL, on @capacms/sdk/next and @capacms/sdk/nextjs, and two commands, capa-codegen --graphql and capa persist. Additive: every call that existed behaves as before. graphql is an optional peer dependency that only the two commands load. The package's types now need TypeScript 5.0 or later, with strict on or off.

    Calls and errors. client.graphql(document, variables?, options?) runs a query against /api/graphql and resolves with { data, errors, extensions, cacheTags } once the API has run it, even when errors is not empty: a root field that failed is null and the others keep their data. A request the API refuses as a whole, errors and no data, throws CapaError whatever its status: a 4xx, or the 200 that GraphQL over HTTP sends on application/json, thrown with status 400. 401, 402, 403, 404, 405 and 429 keep their own. CapaError carries every error of the refusal in graphqlErrors. Each error is a CapaGraphQLError, a plain object: message, locations, path and extensions as the spec writes them, with code, hint, docs, param and type lifted out, so Response.json(result) and a client component's props keep every field. isCapaGraphQLError checks one by its shape. Where GraphQL is switched off the call throws "This Capa deployment does not serve GraphQL." with a hint to read over REST meanwhile. extensions.cost (requestedQueryCost, actualQueryCost, and budget, whose counted is what the 5,000-entry limit checks) and extensions.deprecations ({ coordinate, reason } for each deprecated member a document used) are typed.

    Sending. A read is a GET whenever its URL fits the API's 8,192-byte limit, so a production key's read is cached by the CDN and the API with no option set, and a POST when it does not; no document is refused for its length. method: "POST" always sends a POST. A host that serves GraphQL by POST only (the admin host) answers a GET as a path it does not serve; the read is repeated as a POST, and that host is read by POST for five minutes. A 429 (over the API's read limits: 4 running per project, 3 of them per client, and 64 waiting per client or 256 per key, with /api/entries reads counted in the same limits) is sent again after its Retry-After plus up to 250 ms, up to 3 times; retries: 0 throws it, and a thrown 429 carries retryAfter in seconds, on REST calls too. createClient takes the legacy key a site holds for every read, pk_, sk_ or unprefixed as older tenants were minted, and warns once per process without printing any of it; preview() and draft reads (draftClient, CAPA_DRAFT_KEY) take a cap_ key only.

    Persisted queries. persisted: true sends the document's sha256 as a cacheable GET (a POST when the variables are too long for a URL), and on PersistedQueryNotFound one POST with the document. The API stores a document only for a development key; a hash a host declined is remembered for five minutes and sent by POST meanwhile. capa persist registers a project's documents at build time, with the development key in CAPA_DRAFT_KEY, on CAPA_API_URL (CAPA_ADMIN_URL for a self-hosted stack whose read host stores nothing), and refuses a production key before it sends anything. Every document is pinned (Capa-Persist: pin), so documents registered in the Explorer or a preview never evict it, and one stored without its pin fails the run. A refusal is reported with its code. --release and --env name the build its pins belong to (Capa-Persist: pin; release=<id>; env=<env>), read from Vercel's and Netlify's build variables when not given, so the API keeps the latest production releases' documents before a preview's.

    Typed documents. capa-codegen --graphql checks the project's .graphql files and its #graphql, /* capa */ and gql`` literals against the key's schema, and writes one module: the schema's types, CapaQuery, a TypedDocument per operation with <Name>Models, the models it reads, beside it, a <Name>Fragment per fragment and capaTreeLayout. tagsFor({ namespace: <Name>Models }) tags a Next.js read with every model its query reads, relations and filters through them included, so no model is left out by hand, and with capa:media, which revalidateFromWebhook revalidates when a file in the media library is edited. client.graphql(doc, vars) then infers data and variables with no cast, a literal is typed by its own text, and a document's required variables are required in the call. A literal codegen has not read yet does not compile (RunCapaCodegen). A fragment in a literal of its own, spread with ${FRAGMENT} into a query kept as const, works as in Hydrogen. A field, argument or value a later Capa-Version phases out is @deprecated, and each use is printed with its file, line and reason. --watch, --check, --save-schema and --schema are supported. Both commands read CAPA_API_URL and CAPA_KEY (CAPA_BASE_URL and CAPA_API_KEY still work) from the shell or the project's .env files, as next dev does.

    REST types. capa-codegen without --graphql takes a cap_ key: it writes the model interfaces the /v2 schema gives a legacy key from the key's GraphQL schema, keyed by namespace, with no tenant id, and from a saved schema with --schema. Every field is optional there, an enum is a string and a field GraphQL leaves out is unknown, since GraphQL does not say more; no CAPA_SCHEMA_CHECKSUM is written. Whichever key reads it, the module now parses for any namespace: a field that is not an identifier is quoted ("am/pm_indicator"?: string;), a model whose PascalCase name is not one is named as GraphQL names it (2024_events is _2024Events, its references and Select and Attrs aliases too), and an enum's values are escaped. A read's fields is typed as the API returns it: entries.list<Articles, typeof select> types exactly the fields the select names, each always there and null when it was never filled, an expanded relation as the entry (with fields) or { id, model, missing: true }, one it does not expand as { id, model }, a relation list as { items, pageInfo } and media as { id, url, alt, type, width, height } (EntryFields). A select the compiler cannot read, a string or one typed Select<T>, types every field and each relation as any of the three. A flat read's relations are references, included holds partial entries, and inflate returns the tree read's type. BREAKING for code that compiled against the stored shape (fields.author.name, fields.coauthors[0]), which read undefined or threw at run time. A relation may be named alone in a typed select, as a reference.

    The typed builder. client.graphql.query(selection) builds the document from an object and, with createClient<CapaQuery>(), types the result from exactly what was selected. A misspelled field, argument, filter field or operator, an argument of the wrong type, a sort value outside the enum and a missing required argument (article without args: { id }) do not compile, and the compiler's error names the key and where it was written (SelectionError<"titel is not a field of articles.nodes">). NodeOf<CapaQuery, typeof selection, "articles"> is one entry of a read, for a component's props, and QueryResult the whole of its data. An alias reads a field again in the same request: { latest: { __aliasFor: "articles", args, nodes } } is sent as latest: articles(...), and typed and checked as articles. A Date in args is sent as its ISO text. selectionToDocument returns the text a selection sends. It takes no persisted, and throws a TypeError for it before any request: its document is printed when it runs, so capa persist never stored it, and it is already a cacheable GET.

    Paging, tags and descriptions. The builders page back as the API does: REST's before with a limit is sent as last with before, and before=end as last alone, the end of the list. A model's filter takes _tags, as REST's where takes $tags, with the operators the key's schema declares for it. graphqlSchema(), the builders and codegen read a field's Capa field ... tag from the last line of its description, after the field's label, which is where the API writes it.

    REST's shape. toTree(data, selection, layout) turns a builder result into REST's shape=tree data for the same read, typed from the selection: system keys beside fields, each field under its namespace, media values in REST's public shape and order (id, url, alt, type, width, height), and each entry's model from the layout. A relation list without pageInfo selected is { items }, and a missing item of one is left out, where REST keeps a { id, model, missing: true } slot. The layout is the capaTreeLayout constant codegen writes, so a server reads no schema for it, or a schema read with client.graphqlSchema(). A root alias converts as the field it names; an alias below a root is refused, since REST reads each field once. selectToSelection writes a REST read as a builder selection, and graphqlToSelect takes a selection back to REST as it takes a tool spec.

    The tool spec. buildGraphQLQuery, graphqlToSelect and selectToGraphQL move between the spec the Explorer and @capacms/mcp share, GraphQL text and the equal REST request, written exactly as the API writes it in extensions.capa.rest: the filter as where JSON, and a system key a field shadows as $tags, $createdAt or author.$id. They nest at most 4 relations below the root entry, which is the API's 5 levels of entries, and send each filter value as the type its filter input declares, so has: "true" on a list of true/false values goes as true. They check a filter's names through and, or, not and one hop, refuse a system field GraphQL does not filter (_version) and a hop through a relation the key cannot read without naming the hidden model, and refuse a key a spec does not take (frist) with the keys it does. selectToGraphQL selects what REST returns: all six media fields, and for * or no select the system keys, every field and each relation list as a connection of ids. inflate and Select<T> read $tags, $createdAt and the other $ names as system keys (SystemKey). The schema summary lists the models GraphQL leaves out in restOnly, and the builder refuses one by naming its REST read, never as an unknown model with another model suggested. A read of one entry pages a relation list from a cursor: after on the relation's spec, after: in a select, args.after in a selection. A list read refuses it, as the API does. sort takes one value or a list, at the root too. A spec written as REST writes a read (author.name, author(name), *, a sort of -publishedAt) throws with what to write instead.

    Next.js. graphql() on @capacms/sdk/nextjs reads CAPA_API_URL, CAPA_KEY and CAPA_API_VERSION (a setting in config wins), works out draft and edit mode from { draftMode, headers } as getCapaClient does, and keeps a published read that answered with no errors in Next's data cache, through unstable_cache, under its tags: until one of them is revalidated, or for revalidate seconds. A read with no tags is tagged capa:graphql (GRAPHQL_TAG), which revalidateFromWebhook revalidates on every content and media event; tagsFor({ namespace }) tags a read by the models it reads. Drafts are read uncached, by GET. It is not persisted by default. draftClient, getCapaClient and getPublishedClient take CapaQuery like createClient, and getCapaClient keeps its GraphQL reads, a document or the typed builder, in Next's data cache the same way, under the tags and revalidate each call gives.

    The package root. createClient from @capacms/sdk, the legacy /v2/api client, refuses a cap_ key with a TypeError that names @capacms/sdk/next, before it asks for a tenant id or sends anything, where every read failed as a 401 "Invalid API key". Its CapaError message joins the body with a colon. The README opens with which import is which and links the API reference, and homepage is https://docs.capacms.com/api.

    Edit mode. A GraphQL read in edit mode is marked like a REST read: each object that selected id and model is an entry, and capaAttrs(node, field) and fieldAttrs(node) tag a field GraphQL renamed by its namespace. To know which fields those are, the client reads the key's type and field names once a minute, beside the page's read rather than after it; a document that never says model reads none. toTree keeps the mark, and markGraphQLEntries is exported. With next.5's FieldAttrs<T>: fieldAttrs also takes a GraphQL node and, for a REST entry typed with Model, still returns exactly FieldAttrs<Model>, so the <Model>Attrs aliases codegen writes keep working. TaggableField<E> is exported beside it.

1.0.0-next.5 (not published; its changes shipped in next.6)

  • 1.0.0-next.5. <CapaOverlay adminOrigins={[...]} /> from the new entry point @capacms/sdk/nextjs/overlay: the live preview overlay as one Next.js client component, refreshing with router.refresh() on save (M6). react and next are optional peer dependencies, needed only for that entry. The FieldAttrs<T> type is exported from @capacms/sdk/next, and capa-codegen writes a <Model>Attrs alias beside each <Model>Select, so const a: ArticleAttrs = fieldAttrs(entry) catches a wrong field name at compile time (M5).

1.0.0-next.4 (2026-09-24)

  • 1.0.0-next.4. Edit mode. BREAKING for sites that call capaAttrs(entry, field) with no third argument: it now tags only entries read in edit mode, so a published page ships no data-capa- attributes. Create the client with editMode: true (work it out with editMode({ draftMode, headers }) from @capacms/sdk/nextjs) and every entry it reads, related entries included, is marked. Sites that pass a flag keep working unchanged. New in @capacms/sdk/nextjs: editMode, resolveEditRequest for middleware (checks a capa-edit token with Capa, strips a forged x-capa-edit, returns the private, no-store Cache-Control to set), and the constants EDIT_PARAM, EDIT_HEADER, DRAFT_COOKIE, EDIT_CACHE_CONTROL. New on the client: a path option (config and per call) sent as Capa-Path beside Capa-Page, so Capa can list the concrete URLs an entry appears on. markEditEntries, isEditEntry and CAPA_EDIT are exported from @capacms/sdk/next. Five-minute integration (M6) in @capacms/sdk/nextjs: getCapaClient (keys and edit mode from env), getPublishedClient, createPreviewRoute, exitPreviewRoute and capaMiddleware (preview links, the Published view, edit mode and no-store in one line). Typed fieldAttrs(entry).title on @capacms/sdk/next (M5): a wrong field name is a compile error. Flat responses: entries.list and entries.get take shape: "flat", which sends ?shape=flat and returns every relation as a { id, model } reference with each expanded entry once in included, typed by the select. The result carries the select it sent. inflate(result) turns it back into the tree result, deep-equal to a shape=tree read of the same request; it returns copies and stops where the select stops, so cycles end. New types: FlatPage, FlatSingle, FlatListOptions, FlatGetOptions, Included, ExpandedTargets, ResponseShape, FlatResponse. Additive: a read without shape sends the same URL and returns the same result as before.

1.0.0-next.3 (2026-09-23)

  • 1.0.0-next.3. Reads made from a layout are no longer charged to /. routeOf returned the route a file sits at, so app/layout.tsx came out as / and every Site singleton or nav read in a root layout was recorded as a read of the home page, on every page of the site. routeOf now returns "(layout)" (exported as LAYOUT_PAGE from @capacms/sdk/next and @capacms/sdk/nextjs) for an app-router layout.* or template.*, and a read whose page is "(layout)" sends no Capa-Page, even when the client was created with a page. The return type is still string, so layout code that already passes routeOf(import.meta.url) needs no change: upgrading fixes it. No API change: the API already records nothing for a read without the header. In the pages router, pages/layout.tsx is now the page /layout rather than /.

1.0.0-next.2 (2026-09-23)

  • 1.0.0-next.2. The overlay reports visible { entryId, field }, the tagged element at the centre of the viewport, while the page scrolls: at most every 150ms and only when it changes (the topmost visible element at the very top of a page, the bottommost at the very bottom). The Capa editor's "Follow the page" scrolls the form to match. Additive and still protocol v: 1, so an older admin ignores it and an older overlay simply never sends it. pickCentred and scrollEdge, the pure choice behind it, and visibleMessage are exported for tests.

1.0.0-next.1 (2026-09-23)

  • 1.0.0-next.1. Live preview. capaAttrs(entry, field, enabled) on @capacms/sdk/next tags an element with the entry and field it renders, typed so field is one of the entry's data keys. The new entry point @capacms/sdk/overlay exports startOverlay({ adminOrigins, onRefresh }), which, inside the Capa editor's preview frame, outlines the field being edited, reports clicks on tagged elements back to the editor and re-renders the draft after a save. It does nothing outside a frame and has no dependencies. acceptMessage is exported for tests. See "Live preview" in the README.
  • routeOf(import.meta.url) now decodes the file URL. A real import.meta.url percent-encodes brackets, so a dynamic route such as app/blog/[slug]/page.tsx came out as /blog/%5Bslug%5D and every read with that page threw a TypeError.
  • The package is published as @capacms/sdk. The capa scope on npm was already taken, so the org is capacms; entry points are @capacms/sdk (legacy /v2 client), @capacms/sdk/next and @capacms/sdk/nextjs. Nothing else about the package changed. Earlier notes below that named @capa/sdk were written before the first publish and mean this package.
  • Added page to CapaNextConfig and to every call's options, sending the Capa-Page header so Capa can report which of a site's pages read which entries. Telemetry only: it does not change a response, a cache key or an ETag. A malformed value throws a TypeError, because the API ignores a header it cannot store and a typo should surface where it is written.
  • Added client.pages.list() and client.pages.get(page) for the page list and one page's detail. list({ entry }) narrows to the pages that read one entry and adds entryReads to each row; get returns null for an unknown page.
  • Added client.preview(token), which verifies a preview token minted by the Capa admin and returns the claim, or null when the token is invalid or expired. Every other failure throws.
  • Added schemaChecksum to CapaNextConfig, sending the Capa-Schema header on every call so Capa can tell a site built against the current models from one built against an older set. Telemetry only, like page: it changes no response, cache key or ETag. A malformed value throws a TypeError, because a stamp the API drops in silence looks exactly like a site that is up to date.
  • capa-codegen now writes export const CAPA_SCHEMA_CHECKSUM beside the checksum comment it already wrote, so the value can be imported and handed to createClient. That constant is the only new byte in the generated file.
  • client.pages.get(page) now carries insights, the suggestions Capa draws from that page's own reads (overfetch, fanout, cache, drift), each with the numbers behind it and a copyable rewrite where there is one, and every queries[] row carries selection, the parsed Selection IR of its select, or selectionError when it no longer parses.
  • Every queries[] row on client.pages.get(page) also carries url, the request that group most often made, so a caller can print the real call with its filters, sort and limit instead of reconstructing one from select. It is null once the group has been folded into the daily rollup, which keeps counts rather than requests. The detail itself gains lastReadAt.
  • client.pages.list() now carries meta.insights, the tenant-wide unused rows: entries no page has read in 30 days and nobody has edited in 90.
  • Added routeOf(file), preview(token, client) and pagesFor(client) to @capacms/sdk/nextjs. routeOf turns a Next route file into a page string, dropping route groups, parallel slots, leaf file names and extensions, and throws rather than guessing for files Next does not route.

1.0.0-next.0 (2026-09-22)

  • Added @capacms/sdk/next, the dependency-free /api/ read client for cap_ keys, typed selects, cursor iteration, structured /api/ errors, and surrogate cache tags.
  • Added @capacms/sdk/nextjs helpers for Next fetch caching, surrogate tag construction, webhook revalidation, and server-only draft client selection.
  • Kept the legacy /v2/api client as the root export.
  • Added relation-aware capa-codegen output while preserving byte-identical output for schemas without relations.