@capacms/sdk 1.0.0-next.0 → 1.0.0-next.10

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (92) hide show
  1. package/CHANGELOG.md +450 -0
  2. package/README.md +1754 -156
  3. package/bin/capa-codegen.js +192 -5
  4. package/bin/capa.js +235 -0
  5. package/bin/graphql-project.js +142 -0
  6. package/bin/project-env.js +58 -0
  7. package/dist/client.d.ts +5 -0
  8. package/dist/client.js +17 -0
  9. package/dist/codegen.d.ts +55 -0
  10. package/dist/codegen.js +320 -39
  11. package/dist/config.d.ts +5 -36
  12. package/dist/config.js +47 -1
  13. package/dist/esm/image/index.d.ts +120 -0
  14. package/dist/esm/image/index.js +250 -0
  15. package/dist/esm/image/shared-params.generated.d.ts +190 -0
  16. package/dist/esm/image/shared-params.generated.js +461 -0
  17. package/dist/esm/nextjs/image-loader.d.ts +60 -0
  18. package/dist/esm/nextjs/image-loader.js +67 -0
  19. package/dist/esm/nextjs/overlay.d.ts +30 -0
  20. package/dist/esm/nextjs/overlay.js +75 -0
  21. package/dist/esm/overlay/index.d.ts +32 -0
  22. package/dist/esm/overlay/index.js +576 -0
  23. package/dist/esm/overlay/protocol.d.ts +187 -0
  24. package/dist/esm/overlay/protocol.js +240 -0
  25. package/dist/esm/package.json +4 -0
  26. package/dist/graphql-codegen.d.ts +117 -0
  27. package/dist/graphql-codegen.js +705 -0
  28. package/dist/http.js +1 -1
  29. package/dist/image/index.d.ts +120 -0
  30. package/dist/image/index.js +257 -0
  31. package/dist/image/shared-params.generated.d.ts +190 -0
  32. package/dist/image/shared-params.generated.js +471 -0
  33. package/dist/index.d.ts +2 -2
  34. package/dist/index.js +2 -1
  35. package/dist/next/attrs.d.ts +98 -0
  36. package/dist/next/attrs.js +125 -0
  37. package/dist/next/client.d.ts +176 -32
  38. package/dist/next/client.js +212 -90
  39. package/dist/next/entry-fields.d.ts +162 -0
  40. package/dist/next/entry-fields.js +2 -0
  41. package/dist/next/errors.d.ts +136 -0
  42. package/dist/next/errors.js +214 -0
  43. package/dist/next/field-names.d.ts +37 -0
  44. package/dist/next/field-names.js +145 -0
  45. package/dist/next/graphql/build.d.ts +27 -0
  46. package/dist/next/graphql/build.js +98 -0
  47. package/dist/next/graphql/documents.d.ts +67 -0
  48. package/dist/next/graphql/documents.js +35 -0
  49. package/dist/next/graphql/edit-mode.d.ts +16 -0
  50. package/dist/next/graphql/edit-mode.js +93 -0
  51. package/dist/next/graphql/filter-values.d.ts +34 -0
  52. package/dist/next/graphql/filter-values.js +96 -0
  53. package/dist/next/graphql/introspection.d.ts +89 -0
  54. package/dist/next/graphql/introspection.js +102 -0
  55. package/dist/next/graphql/plan.d.ts +115 -0
  56. package/dist/next/graphql/plan.js +531 -0
  57. package/dist/next/graphql/request.d.ts +228 -0
  58. package/dist/next/graphql/request.js +283 -0
  59. package/dist/next/graphql/rest.d.ts +66 -0
  60. package/dist/next/graphql/rest.js +502 -0
  61. package/dist/next/graphql/selection.d.ts +55 -0
  62. package/dist/next/graphql/selection.js +212 -0
  63. package/dist/next/graphql/sha256.d.ts +13 -0
  64. package/dist/next/graphql/sha256.js +86 -0
  65. package/dist/next/graphql/summary.d.ts +83 -0
  66. package/dist/next/graphql/summary.js +151 -0
  67. package/dist/next/graphql/tree-layout.d.ts +36 -0
  68. package/dist/next/graphql/tree-layout.js +20 -0
  69. package/dist/next/graphql/tree.d.ts +171 -0
  70. package/dist/next/graphql/tree.js +249 -0
  71. package/dist/next/graphql/typed.d.ts +261 -0
  72. package/dist/next/graphql/typed.js +146 -0
  73. package/dist/next/index.d.ts +30 -3
  74. package/dist/next/index.js +34 -1
  75. package/dist/next/inflate.d.ts +51 -0
  76. package/dist/next/inflate.js +243 -0
  77. package/dist/next/key-family.d.ts +31 -0
  78. package/dist/next/key-family.js +66 -0
  79. package/dist/next/select-types.d.ts +58 -5
  80. package/dist/next/system-keys.d.ts +27 -0
  81. package/dist/next/system-keys.js +42 -0
  82. package/dist/nextjs/image-loader.d.ts +60 -0
  83. package/dist/nextjs/image-loader.js +71 -0
  84. package/dist/nextjs/index.d.ts +484 -5
  85. package/dist/nextjs/index.js +704 -9
  86. package/dist/nextjs/overlay.d.ts +30 -0
  87. package/dist/nextjs/overlay.js +78 -0
  88. package/dist/overlay/index.d.ts +32 -0
  89. package/dist/overlay/index.js +596 -0
  90. package/dist/overlay/protocol.d.ts +187 -0
  91. package/dist/overlay/protocol.js +253 -0
  92. package/package.json +70 -15
package/README.md CHANGED
@@ -1,21 +1,36 @@
1
1
  # @capacms/sdk
2
2
 
3
- ## The `/api/` client (`@capacms/sdk/next`)
4
-
5
- Install the SDK from the `next` dist tag:
3
+ The TypeScript SDK for Capa's content API: REST and GraphQL reads typed from
4
+ your models, Next.js caching and live preview, and codegen. The API reference
5
+ is at https://docs.capacms.com/api.
6
6
 
7
7
  ```sh
8
8
  pnpm add @capacms/sdk@next
9
9
  ```
10
10
 
11
+ It runs on Node 18 or later. Its types need TypeScript 5.0 or later, with
12
+ `strict` on or off.
13
+
14
+ | Import | What it is |
15
+ |---|---|
16
+ | `@capacms/sdk/next` | The client for `/api/`: entries, GraphQL, pages and preview. It takes a `cap_` key, or the legacy key a site already holds for reads. Start here. |
17
+ | `@capacms/sdk/nextjs` | Next.js helpers: `graphql()` in a server component, cache tags, draft and edit mode, webhook revalidation. |
18
+ | `@capacms/sdk/nextjs/overlay` | The live preview overlay, as a Next.js client component. |
19
+ | `@capacms/sdk/overlay` | The same overlay without Next. |
20
+ | `@capacms/sdk/image` | Image URLs on Capa's CDN for any framework: `imageUrl`, `srcSet`, `sizes` and `isCapaImageUrl`. |
21
+ | `@capacms/sdk/nextjs/image-loader` | A `next/image` loader that lets Capa's CDN resize, for `images.loaderFile`. |
22
+ | `@capacms/sdk` | The legacy `/v2/api` client, which takes a legacy key and a tenant id and refuses a `cap_` key, and the webhook signature check. |
23
+
24
+ ## The `/api/` client (`@capacms/sdk/next`)
25
+
11
26
  Create one client per key and pin the platform version in code:
12
27
 
13
28
  ```ts
14
29
  import { createClient, CapaError } from "@capacms/sdk/next";
15
30
 
16
31
  const capa = createClient({
17
- baseUrl: process.env.CAPA_BASE_URL!,
18
- apiKey: process.env.CAPA_API_KEY!, // cap_live_... or cap_test_...
32
+ baseUrl: process.env.CAPA_API_URL!,
33
+ apiKey: process.env.CAPA_KEY!, // cap_live_..., cap_test_..., or the legacy key your site has
19
34
  version: "2026-10-01",
20
35
  contract: 1,
21
36
  });
@@ -23,7 +38,32 @@ const capa = createClient({
23
38
 
24
39
  The `/api/` client sends `x-api-key`, `Capa-Version`, optional
25
40
  `Capa-Contract`, and `Accept: application/json`. It never sends
26
- `X-Tenant-Key`; the tenant comes from the `cap_` key.
41
+ `X-Tenant-Key`; the tenant comes from the key.
42
+
43
+ ### Keys
44
+
45
+ A `cap_` key is the key for `/api/`: scoped, and stored hashed. `cap_live_`
46
+ reads published content, `cap_test_` drafts too. Mint one in the Capa admin
47
+ under Developers > Keys.
48
+
49
+ The legacy key your site already holds works too, for every read: a `pk_`
50
+ or `sk_` key, or an older key with no prefix, since every key but `cap_` is
51
+ a legacy key to the API. That covers `entries`, `graphql`,
52
+ `graphqlSchema`, `pages`, `me`, `versions` and `preview`: a site checks an
53
+ editor's preview link with the key it already holds, and Capa refuses a link
54
+ made for another project. The first client built with one prints a warning
55
+ once per process, naming the `cap_` key to mint. Draft reads through the SDK
56
+ keep their rule and take a `cap_` key only: `draftClient`'s `draft` config,
57
+ and `CAPA_DRAFT_KEY` for `getCapaClient` and `graphql()`, throw a `TypeError`
58
+ for a legacy key before any request. An `apiKey` that is not a string is
59
+ refused where the client is built.
60
+
61
+ `CAPA_API_URL` and `CAPA_KEY` are the names every Capa tool reads: the
62
+ `/nextjs` helpers, `capa-codegen` and Capa's MCP server, so one `.env` serves
63
+ them all. `capa persist` registers with the development key in
64
+ `CAPA_DRAFT_KEY`, since only a development key stores a document (see
65
+ Persisted queries). `CAPA_BASE_URL` and `CAPA_API_KEY` still work as
66
+ aliases; when both are set, the first pair wins.
27
67
 
28
68
  ### Entries
29
69
 
@@ -53,8 +93,8 @@ const one = await capa.entries.get("articles", "entry-id", {
53
93
  });
54
94
  ```
55
95
 
56
- `select` may be the grammar string from `docs/api/entries.md` or the object
57
- form above. Lists return `{ data, page, meta, cacheTags }`; singles return
96
+ `select` may be the grammar string the API reference describes
97
+ (https://docs.capacms.com/api/entries) or the object form above. Lists return `{ data, page, meta, cacheTags }`; singles return
58
98
  `{ data, meta, cacheTags }`. `cacheTags` is parsed from the `Surrogate-Key`
59
99
  header. `get()` returns `null` for `404 entry_not_found` and throws every other
60
100
  error.
@@ -64,7 +104,7 @@ Filters use the `/api/` operators:
64
104
  ```ts
65
105
  await capa.entries.list("articles", {
66
106
  filter: {
67
- id: { in: ["a", "b"] },
107
+ id: { in: ["00000000-0000-4000-8000-000000000021", "00000000-0000-4000-8000-000000000022"] },
68
108
  views: { gte: 10 },
69
109
  tags: { hasAny: ["news", "launch"] },
70
110
  },
@@ -75,6 +115,102 @@ await capa.entries.list("articles", {
75
115
  Unknown filter operators throw a local `TypeError` before any request is sent.
76
116
  Per-call `{ signal }` is forwarded to `fetch`.
77
117
 
118
+ #### Typed reads
119
+
120
+ `capa-codegen` writes an interface per model (see Codegen). Pass it, and the
121
+ select's own type, and `fields` is typed as the API returns it:
122
+
123
+ ```ts
124
+ import type { Articles, ArticlesSelect } from "./capa-types"; // written by capa-codegen
125
+
126
+ const select = [
127
+ "title",
128
+ { author: ["name"] },
129
+ { coauthors: { select: ["name"], limit: 3 } },
130
+ ] as const satisfies ArticlesSelect;
131
+
132
+ const typed = await capa.entries.list<Articles, typeof select>("articles", { select });
133
+
134
+ for (const article of typed.data) {
135
+ const { title, author, coauthors } = article.fields;
136
+ if (author && "fields" in author) console.log(title, author.fields.name);
137
+ for (const coauthor of coauthors.items) {
138
+ if ("fields" in coauthor) console.log(coauthor.fields.name);
139
+ }
140
+ article.fields.body; // compile error: the select does not name it
141
+ }
142
+ ```
143
+
144
+ - A relation the select expands is the related entry, with its own `fields`,
145
+ or `{ id, model, missing: true }` when that entry was deleted, is
146
+ unpublished for a production key, or is in a model the key cannot read.
147
+ `"fields" in author` tells them apart.
148
+ - A relation the select names without expanding it (`"author"`, or every
149
+ relation under `*`) is a reference, `{ id, model }`.
150
+ - A relation list is `{ items, pageInfo }`, expanded or not.
151
+ - Media is `{ id, url, alt, type, width, height }`.
152
+ - A field the select names is always there, and `null` when it was never
153
+ filled, as the API writes it. `title?: string` in the model reads as
154
+ `string | null`.
155
+ - A field the select does not name is not there, so reading it does not
156
+ compile.
157
+
158
+ Without `typeof select`, or with a select written as a string, every field is
159
+ typed, and each relation as whichever of the three it may be. `get` and
160
+ `iterate` take the same two types, and `EntryFields<Articles, typeof select>`
161
+ names the type of `fields`.
162
+
163
+ ### Flat responses: each related entry once
164
+
165
+ By default an expanded relation is nested where you selected it, so twenty
166
+ articles by one author carry that author twenty times. Pass `shape: "flat"` and
167
+ every relation comes back as a `{ id, model }` reference, with each expanded
168
+ entry once in `included`, keyed by model namespace and then id:
169
+
170
+ ```ts
171
+ const select = ["title", { author: ["name"] }] as const satisfies Select<Article>;
172
+ const flat = await capa.entries.list<Article, typeof select>("articles", {
173
+ select,
174
+ shape: "flat",
175
+ });
176
+
177
+ flat.data[0].fields.author; // { id: "…", model: "authors" }
178
+ flat.included.authors[authorId].fields; // { name: "Ada Vale" }, typed from Author
179
+ ```
180
+
181
+ `included` is typed by the select: the union of the entry types it expands, at
182
+ any depth, each field of which may be absent, since an entry holds what every
183
+ path that reached it selected. A relation in `data` or in `included` is a
184
+ reference. A select written as a plain string types `included` as
185
+ `Record<string, unknown>`. `get` takes `shape: "flat"` the same way.
186
+ `iterate` reads the tree shape only.
187
+
188
+ `inflate` turns a flat result back into the tree result, deep-equal to what the
189
+ same request without `shape` returns:
190
+
191
+ ```ts
192
+ import { inflate } from "@capacms/sdk/next";
193
+
194
+ const tree = inflate(flat); // { data, page, meta, cacheTags }, typed as the tree read
195
+ ```
196
+
197
+ - It walks the select the request sent. A result from this client carries it
198
+ (`flat.select`); for a body you fetched yourself, pass it:
199
+ `inflate(body, "title,author(name)")`.
200
+ - `$tags`, `$createdAt` and the other `$` names in the select are the system
201
+ keys, as the API reads them, so `select=$tags,tags` on a model with its own
202
+ `tags` field comes back with both.
203
+ - It returns copies. The same author under twenty articles is twenty equal,
204
+ independent objects, as when a tree body is parsed. The input is not changed.
205
+ - Cycles end where the select ends: `a` related to `b` related to `a` is
206
+ inflated to the depth you wrote and no further.
207
+ - An entry reached by two paths holds the union of what they selected, and one
208
+ value per field. If two paths expand the same array relation with different
209
+ `limit` or `sort`, the first one wins, and `inflate` cannot tell them apart.
210
+ Every other request round-trips exactly.
211
+ - In edit mode, included entries are marked, and so is every copy `inflate`
212
+ makes of them.
213
+
78
214
  ### Errors
79
215
 
80
216
  ```ts
@@ -105,7 +241,11 @@ const capa = createClient({ baseUrl, apiKey, version: "2026-10-01", fetch: fetch
105
241
  ```
106
242
 
107
243
  `withCache` merges `{ next: { tags, revalidate } }` into every fetch call.
108
- `tagsFor` builds Capa surrogate keys: `m:`, `e:`, `k:`, and `t:`.
244
+ `tagsFor` builds Capa surrogate keys: `m:`, `e:`, `k:`, and `t:`, by id, and
245
+ `tagsFor({ namespace: "articles" })` builds `capa:model:articles`, the tag
246
+ for a GraphQL read (see GraphQL), and `capa:media`. `revalidateFromWebhook`
247
+ revalidates all of them for the entry and model a webhook names, and for a
248
+ media event, the file's `f:` key and `capa:media`.
109
249
 
110
250
  Webhook revalidation pairs with the existing signature verifier:
111
251
 
@@ -139,19 +279,982 @@ SDK never imports `next/*`:
139
279
  ```ts
140
280
  import { draftMode } from "next/headers";
141
281
  import { draftClient } from "@capacms/sdk/nextjs";
282
+ import type { CapaQuery } from "./capa-graphql"; // written by capa-codegen --graphql
142
283
 
143
- const capa = await draftClient({
284
+ const capa = await draftClient<CapaQuery>({
144
285
  production,
145
286
  draft,
146
287
  isDraft: async () => (await draftMode()).isEnabled,
147
288
  });
148
289
  ```
149
290
 
291
+ `CapaQuery` types the GraphQL builder on the client it returns, as it does
292
+ for `createClient<CapaQuery>`; `getCapaClient<CapaQuery>` and
293
+ `getPublishedClient<CapaQuery>` take it the same way. Without it each helper
294
+ returns an untyped client. `getCapaClient` sends its GraphQL reads as it
295
+ sends its REST reads, which Next does not keep, unless a call gives `tags` or
296
+ `revalidate`; then it keeps them in Next's data cache as `graphql()` does
297
+ (see The typed builder).
298
+
299
+ ## Images
300
+
301
+ Capa serves every upload from its CDN at
302
+ `https://cdn.capacms.com/files/<key>`, and resizes it from the query string.
303
+ `@capacms/sdk/image` builds those URLs for any framework:
304
+
305
+ ```ts
306
+ import { imageUrl, srcSet, sizes } from "@capacms/sdk/image";
307
+
308
+ const hero = { id: "f1", url: "https://cdn.capacms.com/files/sunns_1790971326210_d54fed020806.jpg", alt: "Terrace", type: "image", width: 2400, height: 1600 };
309
+
310
+ imageUrl(hero, { width: 1200, format: "webp" });
311
+ // https://cdn.capacms.com/files/sunns_1790971326210_d54fed020806.jpg?format=webp&width=1200
312
+
313
+ srcSet(hero, [640, 960, 1280, 1920], { format: "webp" });
314
+ // ...?format=webp&width=640 640w, ...?format=webp&width=960 960w, ...
315
+
316
+ sizes({ 768: "50vw", 1280: "33vw" });
317
+ // (min-width: 1280px) 33vw, (min-width: 768px) 50vw, 100vw
318
+ ```
319
+
320
+ `imageUrl(image, options)` and `srcSet(image, widths, options)` take a media
321
+ value as `/api/` returns it (`{ id, url, alt, type, width, height }`), the
322
+ legacy client's `CapaImage`, a CDN URL, or a file key such as
323
+ `sunns_1790971326210_d54fed020806.jpg`.
324
+
325
+ | Option | Values | Default, left out of the URL |
326
+ |---|---|---|
327
+ | `width`, `height` | CSS pixels, whole numbers | the image's own size |
328
+ | `fit` | `inside`, `cover` (crop), `contain` (pad), when both `width` and `height` are given | `inside` |
329
+ | `quality` | 1 to 100 | 80; 60 with `format: "avif"`. Kept in the URL with `avif` and `auto` |
330
+ | `dpr` | 1 to 3; the image is `width × dpr` pixels wide | 1 |
331
+ | `blur` | 1 to 256: a blurred placeholder that many pixels square | none |
332
+ | `format` | `webp`, `avif`, `jpeg`, `png`, `auto` | the image's own format |
333
+
334
+ What the helpers do for you:
335
+
336
+ - **Every URL is already in the order and spelling Capa's CDN serves.** Capa
337
+ answers any other spelling of the same query with a 301, which costs the
338
+ first view of each image a round trip. Defaults are left out, as the CDN
339
+ leaves them out.
340
+ - **Nothing is enlarged.** When the image's own `width` (and, for a box,
341
+ `height`) is known, a request for more pixels than it has is brought down
342
+ to its size, keeping the requested shape. A media value from `/api/` has
343
+ both. `srcSet` lists one candidate at the image's own width in place of the
344
+ wider ones.
345
+ - **`format=auto` is sent only when you pass it.** Note that the CDN
346
+ currently returns JPEG for `format=auto` whatever the browser accepts. Use
347
+ `format: "webp"` until that is fixed.
348
+ - **Anything that is not a Capa image comes back unchanged:** another host's
349
+ URL, a path on your own site, or a media value whose `type` is not
350
+ `image`. `isCapaImageUrl(src)` says which is which.
351
+ - **An older upload's `https://api.capacms.com/files/...` URL is built on
352
+ `cdn.capacms.com`.** It is the same file: the CDN fetches from that host,
353
+ which has no cache in front of it. On the CDN a resized copy is made once
354
+ and cached at the edge.
355
+ - **No image gives `undefined`.** `imageUrl` returns `undefined` for `null`,
356
+ or for a media value whose `url` is null or empty (the legacy API sends an
357
+ unset image as `{ url: "" }`), so for a media value its type is
358
+ `string | undefined`; for a string it is `string`. `srcSet` returns
359
+ `undefined` whenever it has nothing to resize. Either can go straight into
360
+ a `src` or `srcSet` prop.
361
+ - An option the CDN would refuse with a 400 (a width that is not a whole
362
+ number, `quality: 120`, more than 4096 pixels on an edge) throws a
363
+ `TypeError` naming the option.
364
+
365
+ For a crop at a fixed shape, `srcSet` takes `aspectRatio` (width over height)
366
+ in place of `height`:
367
+
368
+ ```ts
369
+ import { srcSet } from "@capacms/sdk/image";
370
+ import type { EntryMedia } from "@capacms/sdk/next";
371
+
372
+ declare const cover: EntryMedia | null;
373
+
374
+ const candidates = srcSet(cover, [640, 1280, 1920], { aspectRatio: 16 / 9, fit: "cover", format: "webp" });
375
+ ```
376
+
377
+ ### Plain HTML
378
+
379
+ Build the attributes wherever the page is rendered, a server template or a
380
+ build script:
381
+
382
+ ```ts
383
+ import { imageUrl, srcSet, sizes } from "@capacms/sdk/image";
384
+ import type { EntryMedia } from "@capacms/sdk/next";
385
+
386
+ const attr = (value: unknown) => String(value ?? "").replace(/&/g, "&amp;").replace(/"/g, "&quot;");
387
+
388
+ function picture(image: EntryMedia): string {
389
+ return `<img
390
+ src="${attr(imageUrl(image, { width: 1280, format: "webp" }))}"
391
+ srcset="${attr(srcSet(image, [480, 800, 1280, 1920], { format: "webp" }))}"
392
+ sizes="${sizes({ 1024: "50vw" })}"
393
+ width="${attr(image.width)}" height="${attr(image.height)}"
394
+ alt="${attr(image.alt)}" loading="lazy">`;
395
+ }
396
+ ```
397
+
398
+ In the browser the result is an ordinary responsive image:
399
+
400
+ ```html
401
+ <img
402
+ src="https://cdn.capacms.com/files/sunns_1790971326210_d54fed020806.jpg?format=webp&width=1280"
403
+ srcset="https://cdn.capacms.com/files/sunns_1790971326210_d54fed020806.jpg?format=webp&width=480 480w, https://cdn.capacms.com/files/sunns_1790971326210_d54fed020806.jpg?format=webp&width=800 800w, https://cdn.capacms.com/files/sunns_1790971326210_d54fed020806.jpg?format=webp&width=1280 1280w, https://cdn.capacms.com/files/sunns_1790971326210_d54fed020806.jpg?format=webp&width=1920 1920w"
404
+ sizes="(min-width: 1024px) 50vw, 100vw"
405
+ width="2400" height="1600" alt="Terrace" loading="lazy">
406
+ ```
407
+
408
+ `@capacms/sdk/image` is an ES module for bundlers and CommonJS for
409
+ `require`, and carries no other part of the SDK, so it adds little to a
410
+ browser bundle.
411
+
412
+ ### React
413
+
414
+ ```tsx
415
+ import { imageUrl, srcSet, sizes } from "@capacms/sdk/image";
416
+ import type { EntryMedia } from "@capacms/sdk/next";
417
+
418
+ export function Hero({ image }: { image: EntryMedia | null }) {
419
+ if (!image?.url) return null;
420
+ return (
421
+ <img
422
+ src={imageUrl(image, { width: 1280, format: "webp" })}
423
+ srcSet={srcSet(image, [640, 960, 1280, 1920], { format: "webp" })}
424
+ sizes={sizes({ 1024: "50vw" })}
425
+ width={image.width ?? undefined}
426
+ height={image.height ?? undefined}
427
+ alt={image.alt ?? ""}
428
+ />
429
+ );
430
+ }
431
+ ```
432
+
433
+ A blurred placeholder is one more URL: `imageUrl(image, { blur: 20, format: "webp" })`
434
+ is a few hundred bytes to show while the full image loads.
435
+
436
+ ### Next.js: let Capa resize for next/image
437
+
438
+ `next/image` sends every image through Next's optimizer unless a loader says
439
+ otherwise, and on Vercel that optimizer is billed by use. A custom loader hands the
440
+ resizing to Capa's CDN instead. Add a loader file:
441
+
442
+ ```ts
443
+ // capa-image-loader.ts (or .js)
444
+ "use client";
445
+ import { createCapaImageLoader } from "@capacms/sdk/nextjs/image-loader";
446
+
447
+ export default createCapaImageLoader({ format: "webp" });
448
+ ```
449
+
450
+ and name it in `next.config`:
451
+
452
+ ```js
453
+ // next.config.mjs
454
+ export default {
455
+ images: {
456
+ loader: "custom",
457
+ loaderFile: "./capa-image-loader.ts",
458
+ },
459
+ };
460
+ ```
461
+
462
+ Then use `<Image>` as usual, with the media value's `url`. Mark any other
463
+ image `unoptimized`, since the loader leaves it as it is and Next warns in
464
+ development that the loader ignores its width:
465
+
466
+ ```tsx
467
+ import Image from "next/image";
468
+ import { isCapaImageUrl } from "@capacms/sdk/image";
469
+ import type { EntryMedia } from "@capacms/sdk/next";
470
+
471
+ export function Cover({ image }: { image: EntryMedia }) {
472
+ const src = image.url ?? "/placeholder.jpg";
473
+ return <Image src={src} unoptimized={!isCapaImageUrl(src)} alt={image.alt ?? ""} width={image.width ?? 1200} height={image.height ?? 800} sizes="(min-width: 1024px) 50vw, 100vw" />;
474
+ }
475
+ ```
476
+
477
+ Next lists a URL per width it wants, and the loader turns each into a Capa
478
+ URL in canonical order: `?format=webp&width=640`, `?format=webp&width=750`,
479
+ and so on. An `<Image>`'s `quality` prop becomes `quality`; without one,
480
+ Capa's default of 80 applies. `createCapaImageLoader` takes `format` and a
481
+ default `quality`; `capaImageLoader`, the default export of
482
+ `@capacms/sdk/nextjs/image-loader`, sets neither, so each image keeps its own
483
+ format. Both are also exported from `@capacms/sdk/nextjs` for server code.
484
+
485
+ The loader resizes only a full Capa URL, on `cdn.capacms.com` or
486
+ `api.capacms.com` (built on the CDN host, as above). Any other `src` goes
487
+ back unchanged: a file in `public/`, another host, or a bare name such as
488
+ `logo.png`, which is your site's file and not a Capa key.
489
+
490
+ The loader never throws, because a throw fails the render of the whole page.
491
+ A `src` or prop the CDN would refuse, such as a src carrying `dpr=2` at a
492
+ width of 3840 (past the 4096-pixel edge) or `quality={0}`, gets the `src`
493
+ back unchanged. A width past 4096 pixels asks for 4096. The loader only sees
494
+ the `src` string, so it cannot know the image's own width: keep
495
+ `deviceSizes` to the widths your layout uses.
496
+
497
+ The loader file needs its own entry, `@capacms/sdk/nextjs/image-loader`,
498
+ because Next runs the loader in the browser on every page with an image, and
499
+ that entry holds the image rules and nothing else.
500
+
501
+ ## GraphQL
502
+
503
+ `/api/graphql` reads the same content as `/api/entries`, with the same key,
504
+ version, limits and error codes. The schema is built for your key: it has
505
+ exactly the models the key can read. The API reference is at
506
+ https://docs.capacms.com/api/graphql.
507
+
508
+ ### In a Next.js server component
509
+
510
+ ```tsx
511
+ // app/blog/page.tsx
512
+ import { draftMode, headers } from "next/headers";
513
+ import { capaAttrs } from "@capacms/sdk/next";
514
+ import { graphql, tagsFor } from "@capacms/sdk/nextjs";
515
+ import { BlogIndexModels } from "./capa-graphql"; // written by capa-codegen --graphql
516
+
517
+ const BLOG_INDEX = `#graphql
518
+ query BlogIndex($first: Int) {
519
+ articles(first: $first, sort: [publishedAt_DESC]) {
520
+ nodes { id model title author { name } }
521
+ }
522
+ }
523
+ `;
524
+
525
+ export default async function Blog() {
526
+ const { data } = await graphql(BLOG_INDEX, { first: 5 }, {
527
+ draftMode,
528
+ headers,
529
+ tags: tagsFor({ namespace: BlogIndexModels }), // articles, and authors for author { name }
530
+ revalidate: 60,
531
+ });
532
+ return (
533
+ <ul>
534
+ {data?.articles?.nodes.map((a) => <li key={a.id} {...capaAttrs(a, "title")}>{a.title}</li>)}
535
+ </ul>
536
+ );
537
+ }
538
+ ```
539
+
540
+ `data` and the variables are typed from the document itself, with no cast,
541
+ once `capa-codegen --graphql` has read it (see Typed documents). Until then
542
+ the call does not compile, and the error says to run it, so a document edited
543
+ since the last run is never silently untyped.
544
+
545
+ Given `tags` or `revalidate`, `graphql()` keeps a published read in Next's
546
+ data cache, through Next's own `unstable_cache`, under the tags you give.
547
+ Given neither, it keeps nothing: the read is sent as a REST read is, and the
548
+ page shows a publish on its next render. `BlogIndexModels` lists the models
549
+ the query reads, which codegen writes beside its types: `articles`, and
550
+ `authors` for `author { name }`. It changes when the query does, so a model
551
+ the query starts reading is never left out. `tagsFor({ namespace })` makes
552
+ one tag per model (`capa:model:articles`), and `capa:media`. The webhook
553
+ route above calls `revalidateFromWebhook`, which revalidates a model's tag
554
+ whenever an entry of the model is published, unpublished or deleted, so the
555
+ page shows the change on the next request. Editing a file in the media
556
+ library, its alt text say, changes no entry, so it revalidates `capa:media`
557
+ instead, and every read tagged by its models shows the new text. How long a
558
+ read is kept:
559
+
560
+ - With neither `tags` nor `revalidate`, not at all: every render reads the
561
+ API, as `entries.list` does, so a site with no webhook route still shows a
562
+ publish on the next request.
563
+ - With `tags` and no `revalidate`, until one of its tags is revalidated.
564
+ With the webhook route, that is until the next publish of a model it reads.
565
+ - With `revalidate: 60`, also at most 60 seconds, so a missed webhook costs
566
+ a minute of stale content at most.
567
+ - With `revalidate: 0`, not at all.
568
+
569
+ A read with a `revalidate` and no `tags` is tagged `capa:graphql`
570
+ (`GRAPHQL_TAG`), which `revalidateFromWebhook` revalidates on every content
571
+ change, so it is never stale after a publish; tag it with its models to
572
+ refresh the page only when one of them changes.
573
+
574
+ A read that answered with `errors` is never kept: a root field that timed
575
+ out is shown once and read again on the next request. Next's fetch cache is
576
+ not used for a GraphQL read, since it keeps every 200 and a GraphQL error is
577
+ a 200, and in Next 15 it keeps nothing without a `revalidate`. A draft and an
578
+ edit-mode page are read uncached. Outside a Next request, in a script or a
579
+ test, the read is simply sent. `unstable_cache` is an option only to supply
580
+ another implementation.
581
+
582
+ `draftMode` and `headers` work out draft and edit mode as `getCapaClient`
583
+ does. Under draft mode the read uses `CAPA_DRAFT_KEY`, a `cap_` key, and
584
+ bypasses the cache.
585
+ In edit mode (draft mode, or the editor's Published view) each entry that
586
+ selected `id` and `model` is marked, so `capaAttrs(node, field)` makes it
587
+ clickable in the Capa editor (see Live preview); a visitor's page carries no
588
+ tags. Leave both out for a page with no preview.
589
+
590
+ `graphql()` reads `CAPA_API_URL`, `CAPA_KEY` and `CAPA_API_VERSION` like the
591
+ other helpers, and a setting you pass in `config` (`baseUrl`, `apiKey`,
592
+ `version`, `fetch`) is used instead of its variable, so a full `config`
593
+ needs no env at all. A published read is a GET, which the CDN and the API
594
+ also cache for the published key; a document too long for a URL goes as a
595
+ POST, which only Next's data cache keeps (see How reads are sent and cached).
596
+ The result's `cacheTags`
597
+ holds the API's `Surrogate-Key` for a GET (`m:<modelId>`, `e:<entryId>`), for
598
+ purging a CDN of your own. `draft: true` or `false` decides draft mode
599
+ yourself. Pass `persisted: true` once your documents are stored with
600
+ `capa persist` (see Persisted queries).
601
+
602
+ ### In a Node script
603
+
604
+ ```ts
605
+ import { createClient, isCapaError } from "@capacms/sdk/next";
606
+
607
+ const capa = createClient({
608
+ baseUrl: process.env.CAPA_API_URL!,
609
+ apiKey: process.env.CAPA_KEY!,
610
+ version: "2026-10-01",
611
+ });
612
+
613
+ const POPULAR = `#graphql
614
+ query Popular { articles(first: 5, filter: { views: { gte: 10 } }) { nodes { id title } } }
615
+ `;
616
+
617
+ const { data, errors, extensions } = await capa.graphql(POPULAR);
618
+
619
+ for (const error of errors) console.warn(error.code, error.message, error.hint, error.path);
620
+ console.log(data?.articles?.nodes, extensions.cost?.actualQueryCost);
621
+ ```
622
+
623
+ For a string codegen has not read, such as one built at run time, pass the
624
+ data type yourself: `capa.graphql<{ version: string }>("{ version }")`.
625
+
626
+ `capa.graphql(document, variables?, options?)` resolves once the API has run
627
+ the document, even when `errors` is not empty: the root fields that worked
628
+ still carry data, a root field that failed is `null` (which is why every list
629
+ root is nullable in the schema and in generated types), and each error is a
630
+ `CapaGraphQLError`.
631
+
632
+ A `CapaGraphQLError` is a plain object: the spec's `message`, `locations`,
633
+ `path` and `extensions`, with `code`, `hint`, `docs`, `param` and `type`
634
+ lifted out of `extensions`. So `Response.json(result)` in a route handler, and
635
+ a server component passing `errors` to a client component, keep every field.
636
+ `isCapaGraphQLError(value)` checks one by its shape, so it holds after JSON.
637
+
638
+ A request the API refuses as a whole throws `CapaError`, whose
639
+ `graphqlErrors` holds every error the API sent: a document that does not
640
+ parse or validate, a variable of the wrong type, a query over a budget, a
641
+ filter the planner refuses. Its body has `errors` and no `data`. GraphQL over
642
+ HTTP sends that refusal as a 200 on `application/json`, which this client
643
+ asks for, and as a 4xx on `application/graphql-response+json`; either way the
644
+ `CapaError` is the same, with `status` 400. Other refusals keep their own
645
+ status: the key (401), the plan (402), the origin (403), the contract (404),
646
+ a mutation (405) and the rate (429).
647
+
648
+ The API runs 4 reads at once per project, GraphQL documents and
649
+ `/api/entries` reads counted together, and at most 3 of them for one client
650
+ of a key, so one busy visitor never holds every slot. Up to 64 more per
651
+ client and 256 per key wait for a slot. Past that it answers 429 with
652
+ `Retry-After`. The client waits that long, plus up to 250 ms, and sends the
653
+ request again, up to 3 times, so a page that reads many things at once from
654
+ one key slows down instead of failing. `retries: 0` throws the first 429
655
+ instead, and `retries: n` allows n repeats. A 429 that is thrown carries
656
+ `retryAfter`, in seconds. A `Retry-After` over 30 seconds is thrown at once
657
+ rather than waited out, and `signal` ends a wait early. `entries.list` and
658
+ `entries.get` share these limits and throw their 429 with `retryAfter`
659
+ rather than repeat it. When an API machine is full of other projects' reads,
660
+ it answers 503 `service_unavailable` with `Retry-After`, which is thrown.
661
+
662
+ Where GraphQL is switched off (`CAPA_API_GRAPHQL=off`), the `CapaError`
663
+ says "This Capa deployment does not serve GraphQL." and its `hint` says to
664
+ read with `entries.list` or `entries.get` meanwhile. Options:
665
+ `operationName`, `method`, `persisted: true`, `retries`, and `signal`.
666
+
667
+ `extensions.cost` is the query's cost as Shopify's APIs report it, in
668
+ entries. `requestedQueryCost` is the most the document can read: every list at
669
+ its full `first` (25 at a root and 100 nested when you give none), capped at
670
+ 5,000, plus 500 for each scan. `actualQueryCost` is what it did read, plus 500
671
+ for each scan that ran. The 5,000 limit is checked before the document runs,
672
+ on a tighter figure: a nested list with no `first` counts 10 for each entry
673
+ above it, not 100, so `{ articles(first: 1) { nodes { coauthors { nodes { name } } } } }`
674
+ passes as 11 entries and requests 101. That figure is `budget.counted`, and
675
+ the limit is `budget.limit`, on every response: it is the number to watch,
676
+ since a document is refused exactly when `counted` passes `limit`, and a
677
+ refusal states it. There is no per-key budget, so there is no
678
+ `throttleStatus`.
679
+
680
+ A document that uses a deprecated field, argument or enum value gets one
681
+ `{ coordinate, reason }` for each in `extensions.deprecations`, such as
682
+ `{ "coordinate": "Articles._folder", "reason": "Read folder instead." }`.
683
+ `capa-codegen --graphql` warns about the same uses before you ship.
684
+
685
+ A scan reads entries the answer does not hold. Each of these is one:
686
+
687
+ - a `totalCount`;
688
+ - a filter or sort through a relation;
689
+ - a root field that filters or sorts by its model's own fields, once however
690
+ many such conditions it has, so
691
+ `articles(first: 10, filter: { featured: { eq: true } })` requests 510;
692
+ - each `contains`, `startsWith`, `endsWith` or `ne` condition past the first;
693
+ - a relation list sorted with `sort`.
694
+
695
+ Filters on `id`, `createdAt`, `updatedAt`, `publishedAt` and `_tags`, and a
696
+ relation's `eq`, are served by an index and cost nothing. The API reference
697
+ has every rule: https://docs.capacms.com/api/graphql#cost.
698
+
699
+ A development key's reads, such as the draft client's, also carry
700
+ `extensions.capa`. Its `cost` breaks the cost down against every limit
701
+ (depth, root fields, connections, nodes, fields) and counts the `scans`. Its
702
+ `rest` names the REST request each root field was answered with. A production
703
+ key's reads leave `extensions.capa` out, which keeps a cached page's answer
704
+ small.
705
+
706
+ #### How reads are sent and cached
707
+
708
+ A read is a GET whenever its URL fits the API's limit of 8,192 bytes (path
709
+ and query string), and a POST when it does not; a long document is never
710
+ refused for its length. `method: "POST"` always sends a POST, and
711
+ `method: "GET"` sends a GET that fits and a POST that does not.
712
+
713
+ | Request | Cached by the API and the CDN |
714
+ |---|---|
715
+ | GET with a production key, no errors | yes: `public, max-age=60`, purged by the entries it read (`cacheTags`) |
716
+ | GET with a development key | no (`no-store`): drafts move |
717
+ | any response with `errors` | no (`no-store`) |
718
+ | GET selecting `me`, `__schema` or `__type` | no (`no-store`) |
719
+ | POST | never |
720
+
721
+ So a document too long for a GET is not cached. Keep it cacheable with
722
+ persisted queries: `persisted: true` sends only the hash by GET (see
723
+ Persisted queries). `graphqlSchema()` reads the introspection by POST, since
724
+ it is never cached. The admin host serves GraphQL by POST only and answers a
725
+ GET as a path it does not serve; a read that did not ask for GET is then
726
+ repeated as a POST, and that host is read by POST for five minutes. A
727
+ `method: "GET"` read there throws "This Capa host does not serve GraphQL by
728
+ GET." In Next.js, `graphql()` from `/nextjs` adds Next's data cache on top
729
+ when a read gives `tags` or `revalidate`, for a published read with no
730
+ errors, a POST included: kept under its `tags` until one is revalidated, or
731
+ for `revalidate` seconds when you give it, and never for a draft.
732
+
733
+ ### The typed builder
734
+
735
+ Write the query as an object and get the result typed from exactly what you
736
+ selected, without writing GraphQL text:
737
+
738
+ ```ts
739
+ import { createClient } from "@capacms/sdk/next";
740
+ import type { CapaQuery } from "./capa-graphql"; // written by capa-codegen --graphql
741
+
742
+ const capa = createClient<CapaQuery>({ baseUrl, apiKey, version: "2026-10-01" });
743
+
744
+ const { data } = await capa.graphql.query({
745
+ articles: {
746
+ args: { first: 5, sort: ["publishedAt_DESC"], filter: { featured: { eq: true } } },
747
+ nodes: { title: true, author: { name: true } },
748
+ },
749
+ });
750
+
751
+ data?.articles?.nodes[0].author?.name; // string | null
752
+ data?.articles?.nodes[0].body; // compile error: not selected
753
+ ```
754
+
755
+ `true` selects a scalar, an object selects fields of a relation or a
756
+ connection, and `args` sits beside the fields of anything that takes
757
+ arguments. A misspelled field, argument, filter field or operator fails to
758
+ compile, even beside a correct one, and so does an argument of the wrong type,
759
+ a sort value that is not in the enum, or a required argument left out
760
+ (`article` without `args: { id }`). The compiler's error names the key and
761
+ where it was written: `titel: true` under `articles.nodes` fails with
762
+ `Type 'true' is not assignable to type 'true & SelectionError<"titel is not a
763
+ field of articles.nodes">'`. A `Date` in `args` is sent as its ISO text.
764
+ Without `CapaQuery` the builder still runs, untyped.
765
+ `selectionToDocument(selection)` returns the text it sends.
766
+
767
+ `query()` takes the options `capa.graphql` takes, except `persisted`. Its
768
+ document is printed when it runs, so `capa persist` never stored it, and it
769
+ is already a GET that the API and the CDN cache. To persist a read, write it
770
+ as a `#graphql` literal (see Persisted queries).
771
+
772
+ In a Next.js server component, read through `getCapaClient`. A read that
773
+ names tags is kept in Next's data cache under them:
774
+
775
+ ```tsx
776
+ // app/blog/page.tsx
777
+ import { draftMode, headers } from "next/headers";
778
+ import { getCapaClient, tagsFor } from "@capacms/sdk/nextjs";
779
+ import type { CapaQuery } from "./capa-graphql";
780
+
781
+ export default async function Blog() {
782
+ const capa = await getCapaClient<CapaQuery>({ draftMode, headers });
783
+ const { data } = await capa.graphql.query(
784
+ { articles: { args: { first: 5, sort: ["publishedAt_DESC"] }, nodes: { id: true, title: true } } },
785
+ { tags: tagsFor({ namespace: "articles" }), revalidate: 60 },
786
+ );
787
+ return <ul>{data?.articles?.nodes.map((a) => <li key={a.id}>{a.title}</li>)}</ul>;
788
+ }
789
+ ```
790
+
791
+ `tags` and `revalidate` work as they do for `graphql()` (see In a Next.js
792
+ server component). A read is kept until a publish of a model its tags name,
793
+ a read with errors is never kept, and a draft or an edit-mode page is read
794
+ uncached. A read with neither is not kept, as the client's REST reads are
795
+ not. `capa.graphql(document)` on the same client takes them too.
796
+
797
+ Read one field twice in one request with an alias: any other key, with
798
+ `__aliasFor` naming the field it reads. A home page's featured and latest
799
+ articles are one request:
800
+
801
+ ```ts
802
+ const { data: home } = await capa.graphql.query({
803
+ articles: { args: { first: 3, filter: { featured: { eq: true } } }, nodes: { id: true, title: true } },
804
+ latest: { __aliasFor: "articles", args: { first: 5, sort: ["publishedAt_DESC"] }, nodes: { id: true, title: true } },
805
+ });
806
+
807
+ home?.latest?.nodes[0].title; // string | null, typed as articles is
808
+ ```
809
+
810
+ The alias is checked as the field it names, arguments and fields included,
811
+ and sent as `latest: articles(...)`. A scalar is aliased with `__aliasFor`
812
+ alone: `{ headline: { __aliasFor: "title" } }`.
813
+
814
+ #### Typing a component's props
815
+
816
+ `NodeOf` names one entry of a builder read, so a component that renders it
817
+ takes exactly what the selection reads, and the two cannot drift:
818
+
819
+ ```tsx
820
+ import type { NodeOf } from "@capacms/sdk/next";
821
+ import type { CapaQuery } from "./capa-graphql";
822
+
823
+ const teasers = {
824
+ articles: { args: { first: 5 }, nodes: { id: true, title: true, author: { name: true } } },
825
+ } as const;
826
+
827
+ // { id: string; title: string | null; author: { name: string | null } | null }
828
+ type Teaser = NodeOf<CapaQuery, typeof teasers, "articles">;
829
+
830
+ function Card({ article }: { article: Teaser }) {
831
+ return <li>{article.title} by {article.author?.name}</li>;
832
+ }
833
+
834
+ const { data } = await capa.graphql.query(teasers);
835
+ const cards = data?.articles?.nodes.map((article) => <Card key={article.id} article={article} />);
836
+ ```
837
+
838
+ It is a node of a list root, the entry of a single root (`article`), or the
839
+ node of an alias (`NodeOf<CapaQuery, typeof home, "latest">`).
840
+ `QueryResult<CapaQuery, typeof teasers>` is the type of `data` as a whole.
841
+
842
+ #### The same data in REST's shape
843
+
844
+ The builder returns GraphQL's shape, because that is what its types describe:
845
+ `articles.nodes[0].author.name`. Code written against `/api/entries` reads
846
+ entries instead: `data[0].fields.author.fields.name`. `toTree` converts one to
847
+ the other:
848
+
849
+ ```ts
850
+ import { toTree } from "@capacms/sdk/next";
851
+ import { capaTreeLayout } from "./capa-graphql"; // written by capa-codegen --graphql
852
+
853
+ const selection = {
854
+ articles: {
855
+ args: { first: 5, sort: ["publishedAt_DESC"] },
856
+ nodes: { id: true, status: true, title: true, author: { id: true, status: true, name: true } },
857
+ },
858
+ } as const;
859
+ const { data } = await capa.graphql.query(selection);
860
+ const { articles } = toTree(data, selection, capaTreeLayout);
861
+ articles?.map((entry) => entry.fields.title); // each a string | null, as entries.list types it
862
+ // deep-equal to (await capa.entries.list("articles", { select: "title,author(name)", sort: ["-publishedAt"], limit: 5 })).data
863
+ ```
864
+
865
+ `capaTreeLayout` is what `toTree` reads of your schema (each model's root
866
+ fields, and which fields are renamed, relations, ids or media), written by
867
+ codegen beside the types, so the conversion reads nothing from the API.
868
+ Without codegen, pass the key's schema instead. That is one introspection
869
+ request, so read it once and reuse it:
870
+
871
+ ```ts
872
+ import { toTree } from "@capacms/sdk/next";
873
+
874
+ const schema = await capa.graphqlSchema(); // one request: read it once, reuse it
875
+ const selection = { articles: { args: { first: 5 }, nodes: { id: true, status: true, title: true } } } as const;
876
+ const { data } = await capa.graphql.query(selection);
877
+ const { articles } = toTree(data, selection, schema); // the same tree as with capaTreeLayout
878
+ ```
879
+
880
+ A selection kept in a variable is declared `as const`, so each `true` and
881
+ each `sort` value keeps its exact type and the result is typed exactly.
882
+
883
+ Each model root field becomes its REST `data`, typed from the selection: a
884
+ list root as an array of entries, a single root as one entry or `null`, and
885
+ `null` for a root that failed (its error is in `errors`). System fields sit beside `fields`
886
+ (`_version` as `version`), every other field sits under `fields` by its
887
+ namespace (`hero_image` as `hero-image`), every entry carries its `model`
888
+ from the schema, whether you selected it or not, a relation list becomes
889
+ `{ items, pageInfo }` (`{ items }` when you did not select its `pageInfo`), a
890
+ relation into a model the key cannot read is
891
+ `{ id, model }` as REST writes it unexpanded (a list of them is
892
+ `{ items, pageInfo }`), and a media value keeps REST's public shape and key
893
+ order, `{ id, url, alt, type, width, height }`, as far as you selected it (a
894
+ list of media is an array of those).
895
+
896
+ The result is deep-equal to the REST response for the same read when the
897
+ selection names the rest of what REST always returns: `id` and `status` on
898
+ every entry, `pageInfo { hasNextPage endCursor }` on each relation list, and
899
+ all six media fields. Its type is then assignable to the entry
900
+ `entries.list<Articles, typeof select>` returns for the same read, so a
901
+ function written for the REST read takes it unchanged. With a typed client,
902
+ a selection without `id` and `status` does not compile. Three things differ by design, because GraphQL
903
+ does not carry what REST says there:
904
+
905
+ - A missing single relation (deleted, or a draft the key cannot see) is `null`
906
+ in GraphQL and `{ id, model, missing: true }` in REST.
907
+ - A missing item of a relation list is left out in GraphQL, so `items` is
908
+ shorter. REST keeps its slot as `{ id, model, missing: true }`.
909
+ - A value that does not fit its field's type is `null` in GraphQL and the raw
910
+ value in REST.
911
+
912
+ Page a list with the GraphQL result's own `pageInfo`; `version`, `me`
913
+ and `entry` are not model roots, and `toTree` refuses them. A root alias
914
+ (`latest: { __aliasFor: "articles", ... }`) becomes the REST data of the field
915
+ it names, under its own key. An alias below a root has no REST equivalent,
916
+ since REST reads each field once, so `toTree` refuses it too.
917
+
918
+ ### Typed documents
919
+
920
+ ```json
921
+ {
922
+ "scripts": {
923
+ "dev": "capa-codegen --graphql --watch --out src/capa-graphql.ts & next dev",
924
+ "prebuild": "capa-codegen --graphql --out src/capa-graphql.ts"
925
+ }
926
+ }
927
+ ```
928
+
929
+ `capa-codegen` reads `CAPA_API_URL` and `CAPA_KEY` from your shell, else from
930
+ `.env.local` and `.env` as `next dev` reads them, so a Next.js site needs no
931
+ extra setup. `--watch` keeps running and writes the module again whenever a
932
+ document changes, and when the key's schema does (it reads the schema again
933
+ every minute). A document with a problem is printed with its file and line,
934
+ and the last good module stays in place.
935
+
936
+ Write a document where you use it, marked in one of three ways, and codegen
937
+ types it by its text:
938
+
939
+ ```ts
940
+ import { createClient, gql } from "@capacms/sdk/next";
941
+
942
+ const LATEST = `#graphql
943
+ query Latest($first: Int) { articles(first: $first) { nodes { id title } } }
944
+ `;
945
+ const ONE = /* capa */ `query One($id: ID!) { article(id: $id) { title views } }`;
946
+ const VERSION = gql(`query Version { version }`);
947
+
948
+ const { data } = await capa.graphql(LATEST, { first: 10 });
949
+ data?.articles?.nodes[0].title; // string | null
950
+ await capa.graphql(LATEST, { first: "10" }); // compile error
951
+ await capa.graphql(ONE); // compile error: id is required
952
+ ```
953
+
954
+ The generated file adds each literal's text to `CapaDocuments` with
955
+ `declare module "@capacms/sdk/next"`, and `client.graphql` and `graphql()`
956
+ from `/nextjs` look the text up, so nothing is imported from it. Keep the
957
+ generated file inside your tsconfig's `include`. A literal is sent exactly as
958
+ written, so it holds one operation and every fragment it spreads, written
959
+ inside it or spread in with `${}` (see Fragments); codegen says so, with the
960
+ file and line, when it does not. A literal of fragments only is a fragment
961
+ source for others.
962
+
963
+ Codegen also reads every `.graphql` file and every gql`...` tagged template
964
+ under `src` (or `--documents dir1,dir2`). A tagged template is a plain string
965
+ to TypeScript, so for those, and for `.graphql` files, import the
966
+ `<Name>Document` codegen writes for every named operation:
967
+
968
+ ```ts
969
+ // src/queries/articles.graphql
970
+ // query ArticlesPage($first: Int) { articles(first: $first) { nodes { id title } } }
971
+
972
+ import { ArticlesPageDocument } from "./capa-graphql";
973
+
974
+ const { data } = await capa.graphql(ArticlesPageDocument, { first: 10 });
975
+ data?.articles?.nodes[0].title; // string | null
976
+ await capa.graphql(ArticlesPageDocument, { first: "10" }); // compile error
977
+ ```
978
+
979
+ #### Fragments
980
+
981
+ ```ts
982
+ import type { ArticleTeaserFragment } from "./capa-graphql";
983
+
984
+ const ARTICLE_TEASER = `#graphql
985
+ fragment ArticleTeaser on Articles { id title }
986
+ `;
987
+
988
+ const TEASERS = `#graphql
989
+ query Teasers($first: Int) { articles(first: $first) { nodes { ...ArticleTeaser } } }
990
+ ${ARTICLE_TEASER}
991
+ ` as const;
992
+
993
+ function teaser(props: ArticleTeaserFragment) {
994
+ return props.title;
995
+ }
996
+
997
+ const { data } = await capa.graphql(TEASERS, { first: 3 });
998
+ data?.articles?.nodes.map(teaser);
999
+ ```
1000
+
1001
+ Write a fragment in a `#graphql` literal of its own and spread it into a
1002
+ query with `${NAME}`, as Hydrogen does. Codegen reads the query with the
1003
+ fragment's text in place, which is exactly the text the program sends, so the
1004
+ query is typed and persisted like any other literal. End the query's template
1005
+ with `as const`: TypeScript keeps the text of a template with `${}` only
1006
+ then, and codegen says so when it is missing. A `${}` that names anything
1007
+ else is text only known at run time, which codegen skips and names.
1008
+
1009
+ Codegen writes a `<Name>Fragment` type for every fragment, from a literal or
1010
+ a `.graphql` file, for a component that takes the fragment's data as props:
1011
+ the nodes of any query that spreads the fragment fit it.
1012
+
1013
+ The generated file also holds the schema's types, `CapaQuery` for the typed
1014
+ builder, `capaTreeLayout` for `toTree`, and `<Name>Models` beside each
1015
+ operation: the models it reads, for its cache tags (see In a Next.js server
1016
+ component). That is each model whose entries it selects, and each model a
1017
+ filter or sort reaches through a relation. A filter or sort passed as a
1018
+ variable counts every model its type can reach, and `entry(id:)` counts every
1019
+ model, since either can read any of them.
1020
+
1021
+ Codegen names, with its file and line, every template it does not read that
1022
+ looks like a query, and says what to change: one left unmarked, graphql`...`
1023
+ (from `/nextjs` that is a request, not a tag), and one with a `${}` that names
1024
+ no literal of the project, whose text is only known at run time.
1025
+
1026
+ A field the key cannot read is a codegen error with its file and line, so a
1027
+ model change fails CI instead of production. A field, argument, filter
1028
+ operator or sort value a later `Capa-Version` phases out is `@deprecated` in
1029
+ the generated types, so your editor strikes it through, and codegen prints
1030
+ each use with its file, line and the reason, while still writing the module.
1031
+ `--check` exits 2 when the committed file is stale. `--save-schema
1032
+ capa-schema.json` writes the schema it read, and `--schema capa-schema.json`
1033
+ reads it back instead of calling the API, for CI without a key. Codegen needs
1034
+ `graphql` installed in your project (`pnpm add -D graphql`); nothing else in
1035
+ the SDK does.
1036
+
1037
+ A literal codegen has not read yet, new or edited since the last run, does
1038
+ not compile: the error says `run capa-codegen --graphql`. Text built at run
1039
+ time is a plain `string` and stays untyped, and so does a call that names its
1040
+ data type, `capa.graphql<{ version: string }>(text)`.
1041
+
1042
+ ### Persisted queries
1043
+
1044
+ Register your documents at build time, with a development key:
1045
+
1046
+ ```sh
1047
+ CAPA_API_URL=https://api.capacms.com CAPA_DRAFT_KEY=cap_test_... capa persist --manifest persisted.json
1048
+ ```
1049
+
1050
+ Then send only their hash, with any key, including the production key your
1051
+ site ships:
1052
+
1053
+ ```ts
1054
+ import { ArticlesPageDocument } from "./capa-graphql";
1055
+
1056
+ const { data } = await capa.graphql(ArticlesPageDocument, { first: 10 }, { persisted: true });
1057
+ ```
1058
+
1059
+ With `persisted: true` the client sends the document's sha256 as one small
1060
+ GET, which the CDN and the API cache for a production key, and the document
1061
+ never travels. Variables too long for a GET URL (8,192 bytes) go with the hash
1062
+ in a POST instead, which works the same way but is not cached. `capa persist` prints `<sha256> <operation> stored, pinned` per
1063
+ operation, hashing the same text `capa-codegen --graphql` exports, so run
1064
+ both from the same documents. A document written as a literal is stored
1065
+ twice, as its `<Name>Document` text and as written (`<Name> (literal)`),
1066
+ since a call with the literal sends that text.
1067
+
1068
+ `capa persist` registers every document with `Capa-Persist: pin`. The API
1069
+ drops unpinned documents first when a project's store is full, so the
1070
+ documents developers register while trying queries in the Explorer or a
1071
+ draft preview never push your site's documents out. A document the API
1072
+ stores without its pin is reported as `stored, not pinned` and the command
1073
+ exits 3, since it is exposed to exactly that. `--manifest` writes each
1074
+ operation's `sha256`, `stored` and `pinned`.
1075
+
1076
+ Name the build too, so a run of preview deploys never pushes production's
1077
+ documents out:
1078
+
1079
+ ```sh
1080
+ capa persist --release "$GIT_COMMIT" --env production
1081
+ ```
1082
+
1083
+ That sends `Capa-Persist: pin; release=<commit>; env=production`. A project
1084
+ keeps up to 2,000 documents and 16 MiB, and when it is full the API keeps
1085
+ the pins of the latest 3 production releases first, then what a production
1086
+ key ran in the last 30 days, then other pins, such as a preview build's.
1087
+ On Vercel and Netlify the command reads both from the build
1088
+ (`VERCEL_GIT_COMMIT_SHA` and `VERCEL_ENV`, or `COMMIT_REF` and `CONTEXT`),
1089
+ so there is nothing to pass. Elsewhere, pass the flags or set `CAPA_RELEASE`
1090
+ and `CAPA_RELEASE_ENV`. A release is 1 to 64 letters, digits, dots, dashes
1091
+ or underscores, such as a commit or a deploy id, and an environment is a
1092
+ lowercase name such as `production` or `preview`. The two go together, and
1093
+ the command checks them before it sends anything. The manifest records them
1094
+ as `release` and `env`.
1095
+
1096
+ Two things decide whether a document is stored:
1097
+
1098
+ - The key. `capa persist` reads the development key from `CAPA_DRAFT_KEY`,
1099
+ the name `/nextjs` reads for drafts (`CAPA_KEY` when that is unset), and
1100
+ refuses a production key before it sends anything. A production key ships
1101
+ in your site's bundle, so it may run a document but never register one.
1102
+ - The host. `capa persist` sends each document to `CAPA_API_URL`, the URL
1103
+ your site already reads. `https://api.capacms.com` passes every POST on to
1104
+ the host that stores documents. On a self-hosted stack whose read host
1105
+ stores nothing, set `CAPA_ADMIN_URL` to the API host your Capa admin uses;
1106
+ it wins over `CAPA_API_URL`. When a host stores nothing, the command says
1107
+ so and names the variable to set.
1108
+
1109
+ When a hash is not stored yet, a production client still gets its data, with
1110
+ no error: the API answers the GET with `PersistedQueryNotFound`, the client
1111
+ sends one POST carrying the document and the hash, and the API runs it and
1112
+ answers `extensions.persistedQuery.registered: false`. The client then
1113
+ remembers that hash for five minutes and sends POST straight away, so a
1114
+ missing registration costs one extra GET per five minutes rather than one per
1115
+ call. `registered: false` on a production read is how to spot a document
1116
+ `capa persist` did not register. `graphql()` from `/nextjs` is not persisted
1117
+ by default for the same reason: until `capa persist` has run, every hash
1118
+ misses.
1119
+
1120
+ A builder read (`capa.graphql.query`) cannot be persisted, and
1121
+ `persisted: true` there throws a `TypeError` before any request. Its
1122
+ document is printed when it runs, so `capa persist` never sees it, and a
1123
+ production key never stores one. It is already a cacheable GET.
1124
+
1125
+ ### A REST select as a builder read, and back
1126
+
1127
+ `selectToSelection` turns a REST read into a typed builder selection, which
1128
+ `client.graphql.query` runs and `toTree` turns into that read's `data`.
1129
+ `graphqlToSelect` gives a builder selection's REST request, as it gives a
1130
+ tool spec's:
1131
+
1132
+ ```ts
1133
+ import { graphqlToSelect, selectToSelection, toTree } from "@capacms/sdk/next";
1134
+
1135
+ const schema = await capa.graphqlSchema();
1136
+ const selection = selectToSelection(schema, "articles", "title,author(name)", { sort: ["-views"], limit: 5 });
1137
+ const { data } = await capa.graphql.query(selection);
1138
+ const { articles } = toTree(data, selection, schema);
1139
+ // deep-equal to (await capa.entries.list("articles", { select: "title,author(name)", sort: ["-views"], limit: 5 })).data
1140
+
1141
+ graphqlToSelect(schema, { articles: { args: { first: 5 }, nodes: { title: true, author: { name: true } } } }).url;
1142
+ // "/api/entries/articles?select=title,author(name)&limit=5"
1143
+ ```
1144
+
1145
+ The selection names what `toTree` needs to answer what REST answers: `id`,
1146
+ `model` and `status` on every entry, `pageInfo` on every relation list, and
1147
+ all six media fields. Its fields are known only when it runs, so its data and
1148
+ its tree are untyped, with a typed client too. It takes what `selectToGraphQL`
1149
+ takes and refuses what it refuses. A relation the select names bare, which
1150
+ REST returns unexpanded as `{ id, model }`, is read expanded to its id, the
1151
+ REST read `author(id)`, because GraphQL reads a relation to a model the key
1152
+ can read as an entry. `graphqlToSelect` reads a selection's one model root
1153
+ field, and throws `CapaBuildError` for any other root (`version`, `entry`),
1154
+ and for a relation list read from a cursor in a list read, which have no REST
1155
+ request here. `last` with no `before`, or with `before: null` as Relay and
1156
+ Apollo send it, reads the end of the list: REST's `before=end`.
1157
+
1158
+ ### The tool spec: build a query for an explorer or an assistant
1159
+
1160
+ Tools that build queries (the admin's Explorer, the MCP server) share one
1161
+ plain format, the tool spec: `{ model, fields, first, sort, filter }`. The SDK
1162
+ reads the key's schema once and writes GraphQL from it in Capa's canonical
1163
+ format, and moves between it and a REST read:
1164
+
1165
+ ```ts
1166
+ import { buildGraphQLQuery, graphqlToSelect, selectToGraphQL } from "@capacms/sdk/next";
1167
+
1168
+ const schema = await capa.graphqlSchema(); // models, fields, filters, sorts, from one request
1169
+
1170
+ const spec = { model: "articles", fields: ["title", { field: "author", fields: ["name"] }], sort: ["views_DESC"], first: 5 };
1171
+ const { query, variables } = buildGraphQLQuery(schema, spec);
1172
+ graphqlToSelect(schema, spec).url;
1173
+ // "/api/entries/articles?select=title,author(name)&sort=-views&limit=5"
1174
+
1175
+ selectToGraphQL(schema, "articles", "title,author(name)", { sort: ["-views"], limit: 5 });
1176
+ // the same tool spec, from a REST read
1177
+ ```
1178
+
1179
+ The tool spec is not the typed builder's selection: `client.graphql.query`
1180
+ and `toTree` take the selection, which `selectToSelection` writes.
1181
+
1182
+ `graphqlToSelect` writes the REST twin exactly as the API writes it in
1183
+ `extensions.capa.rest`: the filter as `where` JSON, the root's default page
1184
+ size left out, a relation list's `first` written as `limit:` whenever you give
1185
+ it (100 included, since REST counts a limit you write in full and one you
1186
+ leave out as 10), and a system key a field of the model shadows written with `$`
1187
+ (`_tags` beside a field called `tags` is `select=$tags,tags`; `createdAt_DESC`
1188
+ beside a field called `createdAt` is `sort=-$createdAt`). That includes a
1189
+ field GraphQL leaves out because its name collides with another's, which the
1190
+ type's description lists as `Not exposed:` and the schema summary as
1191
+ `notExposed`: beside a hidden field called `id`, `id_DESC` is `sort=-$id`.
1192
+ `selectToGraphQL` reads `$` names back, and refuses a REST name for a hidden
1193
+ field, which GraphQL cannot read. It selects what the REST read returns: a
1194
+ media value with all six of its fields (`id url alt type width height`), and
1195
+ `*`, or no select, as everything: the system keys `createdAt`, `updatedAt`,
1196
+ `publishedAt`, `version`, `folder` and `tags`, every field, and each relation
1197
+ list as a connection of ids (`coauthors { nodes { id } }`), the page of
1198
+ references REST returns. A reference to an entry the key cannot see is the
1199
+ one difference left: REST shows it as `{ id, model, missing: true }`, and
1200
+ GraphQL reads it as `null`, or leaves it out of a list. `buildGraphQLQuery`
1201
+ with no `fields` keeps its own shorter default, media as `id url alt`. Both helpers send each filter value as the type its
1202
+ filter input declares, since GraphQL refuses any other: `"10"` for a number
1203
+ field becomes `10`, and `has: "true"` on a list of true/false values becomes
1204
+ `true`, which its `BooleanListFilter` takes. A name the schema does not have,
1205
+ or a key a relation's spec does not take (`frist` for `first`, at any depth),
1206
+ throws `CapaBuildError` with `didYouMean`, before anything is sent. A `where`
1207
+ on `$tags` ports to GraphQL's `_tags` filter:
1208
+
1209
+ ```ts
1210
+ import { selectToGraphQL } from "@capacms/sdk/next";
1211
+
1212
+ const schema = await capa.graphqlSchema();
1213
+ selectToGraphQL(schema, "articles", "title", { where: { $tags: { has: "news" } } });
1214
+ // { model: "articles", fields: ["title"], filter: { _tags: { has: "news" } } }
1215
+ ```
1216
+
1217
+ A model GraphQL leaves out, because its type name would collide with
1218
+ another's (`twin_a` and `twin-a`), is listed in the summary's `restOnly`.
1219
+ Asked for one, the builder throws `CapaBuildError` naming its REST read
1220
+ (`twin_a is readable over REST only: GET /api/entries/twin_a.`) and why,
1221
+ with no `didYouMean`: `entries.list("twin_a")` reads it.
1222
+
1223
+ Two REST reads have no GraphQL twin and throw too: a `where` on `$version`,
1224
+ `$folder`, `$model` or `$status` (GraphQL filters `id`, `createdAt`,
1225
+ `updatedAt`, `publishedAt` and `_tags`), and a filter hop through a relation
1226
+ the key cannot read (REST answers it `unknown_field`).
1227
+
1228
+ A tool spec pages as a REST read does: `first` with `after` reads the page after a
1229
+ cursor, and `first` with `before` the entries just before one. GraphQL writes
1230
+ the second as `last` with `before`, and the API refuses `first` there, so
1231
+ `{ first: 25, before }` prints `articles(last: $last, before: $before)`, and
1232
+ a tool spec with both `after` and `before` throws, as the API refuses it.
1233
+ `before: "end"` reads the last entries of the list, REST's `before=end`:
1234
+ `{ first: 5, before: "end" }` prints `articles(last: $last)`, and the list's
1235
+ `startCursor` pages back from there. `after: "end"` throws, since `end` is not
1236
+ a cursor.
1237
+ A read of one entry also pages a relation list from its cursor, as REST's
1238
+ `coauthors(name,after:…)` does: `{ field: "coauthors", fields: ["name"],
1239
+ after }` in a spec with `mode: "single"`, or `args: { after }` on the list in
1240
+ a selection. A list read throws for it, as the API refuses it there
1241
+ (`after: applies to a single entry`). `sort` takes one value or a list, at
1242
+ the root as on a relation list.
1243
+
1244
+ A spec written as REST writes a read throws, and says what to write instead:
1245
+ `"author.name"` or `"author(name)"` in `fields` is
1246
+ `{ field: "author", fields: ["name"] }`, `"*"` is `fields` left out, and a
1247
+ sort `"-publishedAt"` is `"publishedAt_DESC"` (in `didYouMean` too).
1248
+ Relations nest up to 4 deep below the root entry, which with the root is the
1249
+ API's 5 levels of entries; a fifth throws `CapaBuildError` and names the field
1250
+ to select without fields, for its id.
1251
+
150
1252
  ## Typed select and codegen
151
1253
 
152
1254
  `Select<T>` is exported from `@capacms/sdk/next`. `capa-codegen` keeps the legacy
153
1255
  `/v2/schema/types` shape for schemas without relations. When a schema has
154
- relations, codegen wraps them in branded helpers:
1256
+ relations, codegen wraps them in branded helpers, which describe the model;
1257
+ a read's `fields` is typed from them as the API returns it (see Typed reads):
155
1258
 
156
1259
  ```ts
157
1260
  export type CapaRelation<T> = T & { readonly __capaRelation: "one"; readonly __capaRelationTarget: T };
@@ -167,7 +1270,31 @@ export type ArticleSelect = import("@capacms/sdk/next").Select<Article>;
167
1270
  ```
168
1271
 
169
1272
  Those brands let TypeScript tell scalar fields from relation fields, so misspelled
170
- fields and invalid nested selects fail in consumer typechecks.
1273
+ fields and invalid nested selects fail in consumer typechecks. A relation named
1274
+ alone (`"author"`) is read as a reference.
1275
+
1276
+ Every name is written so the module parses, whatever the namespace holds. A
1277
+ field that is not an identifier is quoted (`"am/pm_indicator"?: string;`,
1278
+ read as `fields["am/pm_indicator"]`), and a model whose name is not one is
1279
+ named as GraphQL names it: `2024_events` is `_2024Events`, with
1280
+ `_2024EventsSelect` beside it. Two models whose names give one interface name,
1281
+ such as `twin_a` and `twin-a`, are each named from the whole namespace instead:
1282
+ `Model_twin_a` and `Model_twin$2da`, each character that is not a letter, digit
1283
+ or `_` written as `$` and its hex code. An enum's values are written as stored,
1284
+ a quote or a backslash included.
1285
+
1286
+ A select names a field by its namespace as saved, whatever it holds:
1287
+ `["price.usd", { "at.place": ["zip.code"] }]`. The client writes a name holding
1288
+ `,` `(` `)` `:` `.` `"` `*` `[` `]` or a space, or starting with `-`, quoted, as
1289
+ REST's grammar reads it: `select="price.usd","at.place"("zip.code")`. A string
1290
+ select, `sort` and `where` take REST's own text, so write such a name quoted
1291
+ there yourself: `sort: ['-"price.usd"']`.
1292
+
1293
+ A select also takes the entry's system keys by their `$` names, which mean the
1294
+ system key even on a model with a field of the same plain name
1295
+ (`SystemKey`): `["title", "$tags", { coauthors: { select: ["name"], sort: "-$createdAt" } }]`.
1296
+ A `$` name that is no system key fails to compile. A field whose namespace
1297
+ starts with `$` cannot be named, so `select: "*"` is how to read it.
171
1298
 
172
1299
  ## Pages and preview
173
1300
 
@@ -192,6 +1319,15 @@ const capa = createClient({ baseUrl, apiKey, version: "2026-10-01" });
192
1319
  const posts = await capa.entries.list("articles", { page: routeOf(import.meta.url) });
193
1320
  ```
194
1321
 
1322
+ Send `path` beside it, the concrete path being rendered, and Capa can list the
1323
+ real URLs an entry appears on, not only the route patterns:
1324
+
1325
+ ```ts
1326
+ await capa.entries.list("articles", { page: "/blog/[slug]", path: `/blog/${slug}` });
1327
+ ```
1328
+
1329
+ `path` goes out as `Capa-Path`, only when `page` is also set.
1330
+
195
1331
  `routeOf` turns a Next route file into the page string: `/blog/[slug]`. It
196
1332
  drops route groups `(marketing)`, parallel slots `@modal`, the leaf file name
197
1333
  and the extension. Write the string out by hand if you prefer; `routeOf` exists
@@ -199,6 +1335,19 @@ so that moving a folder cannot silently split one page's telemetry in two. Set
199
1335
  `page` on the config instead when a client serves exactly one page; a value on
200
1336
  the call wins over one on the config.
201
1337
 
1338
+ A layout is not a page. `app/layout.tsx` (and any nested `layout.*` or
1339
+ `template.*`) renders around every page below it, and Next does not tell it
1340
+ which one, so its reads cannot be charged to the page being rendered. `routeOf`
1341
+ returns `"(layout)"` for these files, exported as `LAYOUT_PAGE`, and a read that
1342
+ names it sends no `Capa-Page` header at all, even when the client was created
1343
+ with a `page`. So a Site singleton or a nav read in your root layout is simply
1344
+ not attributed, instead of making `/` look as if it read everything:
1345
+
1346
+ ```ts
1347
+ // In app/layout.tsx: same call as in a page, and no page is recorded.
1348
+ const site = await capa.entries.list("site", { page: routeOf(import.meta.url) });
1349
+ ```
1350
+
202
1351
  A malformed value throws a `TypeError`. Capa itself ignores a header it cannot
203
1352
  store, because a mangled page identity must never take a blog down, so the SDK
204
1353
  is the place a typo surfaces.
@@ -252,7 +1401,7 @@ exactly like a site that is up to date.
252
1401
 
253
1402
  `capa.pages.get(page)` carries an `insights` array: suggestions drawn from that
254
1403
  page's own reads, computed by Capa so that this SDK, the Capa admin and
255
- `@capa/mcp` all read one answer.
1404
+ Capa's MCP server all read one answer.
256
1405
 
257
1406
  ```ts
258
1407
  const detail = await capa.pages.get("/blog/[slug]");
@@ -326,105 +1475,534 @@ const capa = await draftClient({
326
1475
  Set the preview base URL for your project in Capa (Settings), otherwise the
327
1476
  editor sees the path without a link to open it.
328
1477
 
329
- ## Not yet
1478
+ ## Live preview
330
1479
 
331
- `--select-from-depth`, `capa convert-url`, `capa persist`, GraphQL, and `/api/`
332
- writes are not in this release.
1480
+ ### Quick start (Next.js)
333
1481
 
334
- ## The legacy `/v2/api` client
1482
+ Preview needs `@capacms/sdk` 1.0.0-next.8 or later. The `latest` tag is
1483
+ older and has no `capaHeaders`, so install the `next` tag:
1484
+
1485
+ ```sh
1486
+ pnpm add @capacms/sdk@next
1487
+ ```
335
1488
 
336
- A read client for Capa, plus two arrangement writes (`workspaces`,
337
- `models.setLayout`) and `capa-codegen`, a command that pulls a tenant's
338
- generated TypeScript so content is typed at the call site.
1489
+ Set `CAPA_API_URL`, `CAPA_KEY` (`cap_live_`, or the legacy key your site
1490
+ holds) and `CAPA_DRAFT_KEY` (`cap_test_`). Draft reads through the SDK take a
1491
+ `cap_` key only (see Keys). Then:
339
1492
 
340
1493
  ```ts
341
- import { createClient } from "@capacms/sdk";
342
- import type { BlogPost } from "./capa-types"; // written by capa-codegen
1494
+ // middleware.ts
1495
+ import { NextResponse } from "next/server";
1496
+ import { capaMiddleware } from "@capacms/sdk/nextjs";
1497
+ export const middleware = capaMiddleware({ NextResponse });
1498
+
1499
+ // app/api/capa/preview/route.ts (and exit/route.ts with exitPreviewRoute)
1500
+ import { cookies, draftMode } from "next/headers";
1501
+ import { createPreviewRoute } from "@capacms/sdk/nextjs";
1502
+ export const GET = createPreviewRoute({ draftMode, cookies });
1503
+
1504
+ // next.config.mjs
1505
+ import { capaHeaders } from "@capacms/sdk/nextjs";
1506
+ export default { async headers() { return capaHeaders(); } };
1507
+
1508
+ // in a page: getCapaClient({ draftMode, headers }), then <h1 {...fieldAttrs(post).title}>
1509
+ // in the root layout: const edit = await editMode({ draftMode, headers }) from @capacms/sdk/nextjs,
1510
+ // then {edit ? <CapaOverlay adminOrigins={[...]} /> : null} from @capacms/sdk/nextjs/overlay
1511
+ ```
343
1512
 
344
- const capa = createClient({
345
- baseUrl: process.env.CAPA_BASE_URL!,
346
- apiKey: process.env.CAPA_API_KEY!,
347
- tenantId: process.env.CAPA_TENANT_ID!,
348
- });
1513
+ In Capa, set the project's preview URL to your site, and editors can click
1514
+ your page. The sections below explain each piece.
349
1515
 
350
- const page = await capa.listContent<BlogPost>("blog_post", { limit: 10 });
351
- const one = await capa.findOne<BlogPost>("blog_post", { handle: "hello" });
1516
+ The Capa editor can show your site beside the form: focus a field and its spot
1517
+ on the page is outlined, click the page and the editor jumps to the field, save
1518
+ and the draft re-renders in place. Where live preview is on, the text being
1519
+ typed shows on the page before the save (see the protocol below). It needs three things on your side.
1520
+
1521
+ ### 1. Turn on edit mode and tag what an editor can click
1522
+
1523
+ Edit mode is on when Next draft mode is on, or when the request carries a
1524
+ `capa-edit` token the Capa editor's Published view sends. Work it out once per
1525
+ request and build the client with it:
1526
+
1527
+ ```ts
1528
+ // lib/capa.ts
1529
+ import { draftMode, headers } from "next/headers";
1530
+ import { createClient } from "@capacms/sdk/next";
1531
+ import { editMode } from "@capacms/sdk/nextjs";
1532
+
1533
+ export async function capa() {
1534
+ const draft = (await draftMode()).isEnabled;
1535
+ return createClient({
1536
+ baseUrl, version: "2026-10-01",
1537
+ apiKey: draft ? process.env.CAPA_DRAFT_KEY! : process.env.CAPA_KEY!,
1538
+ editMode: await editMode({ draftMode, headers }),
1539
+ });
1540
+ }
352
1541
  ```
353
1542
 
354
- ## Codegen
1543
+ Then tag fields with `capaAttrs(entry, field)` from `@capacms/sdk/next`. `field`
1544
+ is the field's namespace, its key in `entry.fields`, and it autocompletes when
1545
+ the entry is typed. There is no flag to pass: an entry read by an edit-mode
1546
+ client carries a hidden mark (related entries too), and `capaAttrs` tags only
1547
+ marked entries. A visitor's page therefore ships no `data-capa-` attributes.
355
1548
 
1549
+ ```tsx
1550
+ import { capaAttrs } from "@capacms/sdk/next";
1551
+
1552
+ <h1 {...capaAttrs(article, "title")}>{article.fields.title}</h1>
356
1553
  ```
357
- CAPA_BASE_URL=... CAPA_API_KEY=... CAPA_TENANT_ID=... capa-codegen --out src/capa-types.ts
1554
+
1555
+ The mark does not survive a spread copy or being passed to a client component,
1556
+ so tag in the server component that read the entry. `capaAttrs(entry, field,
1557
+ true)` still forces the tags on and `false` forces them off.
1558
+
1559
+ **When the entry crosses to the browser as data, pass the flag.** The mark is
1560
+ a hidden symbol, so anything that serializes the entry drops it silently: the
1561
+ Next Pages Router's `getServerSideProps`, SvelteKit and Remix loaders, Nuxt's
1562
+ payload, or your own JSON endpoint. The page then shows the draft with no
1563
+ `data-capa-` tags, and the editor has nothing to point at. Nothing errors. Send
1564
+ the draft or edit state alongside the entry and hand it to `capaAttrs`:
1565
+
1566
+ ```tsx
1567
+ // pages/articles/[id].tsx on the Pages Router. A +page.server.ts load or
1568
+ // useAsyncData on the server hands over the flag the same way.
1569
+ import type { Entry } from "@capacms/sdk/next";
1570
+ import type { Articles } from "./capa-types";
1571
+
1572
+ export async function getServerSideProps({ draftMode = false }) {
1573
+ const entry = (await capa.entries.get<Articles>("articles", "entry-id"))!.data;
1574
+ return { props: { entry, edit: draftMode } };
1575
+ }
1576
+
1577
+ // in the component
1578
+ export default function Page({ entry, edit }: { entry: Entry<Articles>; edit: boolean }) {
1579
+ return <h1 {...capaAttrs(entry, "title", edit)}>{entry.fields.title}</h1>;
1580
+ }
358
1581
  ```
359
1582
 
360
- Exit codes: `0` wrote or already current, `1` failed, `2` `--check` found a diff.
361
- `--check` is the CI mode — it fails when the committed file is stale.
1583
+ Or re-mark the entries where they arrive with `markEditEntries(data)` when the
1584
+ page is in edit mode.
362
1585
 
363
- **Commit the output.** Not generated at install (needs credentials during
364
- `npm install`, which breaks CI images and Docker builds) and not at build time
365
- (makes every build network-dependent, and a Capa outage a build outage). A
366
- committed file is also what makes the feature useful: "a model changed, so the
367
- build fails" only helps if a human can see *what* changed, and a committed file
368
- turns that into a reviewable diff instead of a wall of `tsc` errors.
1586
+ GraphQL reads are marked the same way, from `getCapaClient`, an edit-mode
1587
+ `createClient` or `graphql()` given `{ draftMode, headers }`. A node is an
1588
+ entry when it selected `id` and `model`, and `field` is the field as you
1589
+ selected it:
369
1590
 
370
- Re-running when nothing changed costs **one conditional request returning 0
371
- bytes** — see below.
1591
+ ```tsx
1592
+ const { data } = await capa.graphql.query({ articles: { nodes: { id: true, model: true, title: true } } });
1593
+ <h1 {...capaAttrs(data!.articles!.nodes[0], "title")}>{data!.articles!.nodes[0].title}</h1>
1594
+ ```
372
1595
 
373
- ## Three server-side rough edges, handled here
1596
+ A field GraphQL renamed (`hero_image` for `hero-image`) is tagged by its
1597
+ namespace, which the editor knows it by: in edit mode the client reads the
1598
+ names of the key's types and fields once a minute to know which fields those
1599
+ are. That read starts beside the page's read, so the page waits for the
1600
+ slower of the two, and a document that never says `model` reads no names.
1601
+ With a typed client, tagging a node that did not select `model` does not
1602
+ compile.
1603
+ `toTree` keeps the mark, so its entries tag like REST entries, by namespace.
374
1604
 
375
- The API is mid-port to a byte-parity bar, so changing a customer-facing response
376
- costs a signed exception. None of these is worth one, because each can be
377
- handled on this side.
1605
+ To accept `capa-edit`, verify it in middleware. The token is checked with Capa,
1606
+ any forged `x-capa-edit` header is removed, and edit-mode responses are marked
1607
+ `private, no-store`:
378
1608
 
379
- **1. `/v2/schema/types` has no ETag,** so it cannot be revalidated. `/v2/schema`
380
- *does* — it returns a `checksum` and honours `If-None-Match` with a 304, and that
381
- checksum is `sha256({models, relations})`, exactly what the types are generated
382
- from. So codegen gates on it and fetches types only when the schema moved.
1609
+ ```ts
1610
+ // middleware.ts
1611
+ import { NextResponse, type NextRequest } from "next/server";
1612
+ import { resolveEditRequest } from "@capacms/sdk/nextjs";
1613
+
1614
+ export async function middleware(request: NextRequest) {
1615
+ const edit = await resolveEditRequest(request, publishedClient());
1616
+ const response = NextResponse.next({ request: { headers: edit.headers } });
1617
+ if (edit.cacheControl) response.headers.set("Cache-Control", edit.cacheControl);
1618
+ if (edit.robotsTag) response.headers.set("X-Robots-Tag", edit.robotsTag);
1619
+ return response;
1620
+ }
1621
+ ```
383
1622
 
384
- **2. The generator orders models by display name,** while interfaces are *named*
385
- from the namespace (`blogs_home_section` → `BlogsHomeSection`). Renaming a
386
- model's display name therefore reorders the whole file without changing a single
387
- type — a false "your types changed" in the exact mechanism this feature rests
388
- on. `normalizeTypes` re-sorts by interface name, so the committed file is stable
389
- under renames.
1623
+ ### 2. Start the overlay in edit mode
390
1624
 
391
- **3. A model field named `id` overwrites the instance id** (#4628). Rows are
392
- built as `{...instance, ...formatInstanceData(instance)}` (`routes/v2/api.ts`).
393
- Hoisted fields are spread second, so `id`, `title` and `tags` are overwritten.
394
- In one ordinary tenant, 20 of 20 models shadow `title` and 7 of 20 shadow `id`.
1625
+ ```tsx
1626
+ // app/layout.tsx
1627
+ import { CapaOverlay } from "@capacms/sdk/nextjs/overlay";
1628
+
1629
+ {edit ? <CapaOverlay adminOrigins={["https://app.capacms.com"]} /> : null}
1630
+ ```
1631
+
1632
+ `@capacms/sdk/nextjs/overlay` is a client component and needs `react` and
1633
+ `next`. Without Next, call `startOverlay({ adminOrigins, onRefresh })` from
1634
+ `@capacms/sdk/overlay` in your own effect.
1635
+
1636
+ Render it from your root layout only in edit mode
1637
+ (`await editMode({ draftMode, headers })`), so it never runs for a visitor.
1638
+ Imported this way, its code still sits in the layout's chunk, which every
1639
+ visitor downloads. To keep it out, load it lazily from a small client
1640
+ component, as step 5 of "Add preview to a live site without changing it"
1641
+ shows. Both overlay entry points reach a bundler as ES modules, so loading
1642
+ the overlay leaves the rest of a visitor's chunks as they were.
1643
+
1644
+ `startOverlay` returns a disposer and is safe to call twice. Outside a frame it
1645
+ does nothing at all, and inside one it only listens to a parent window at one of
1646
+ `adminOrigins`.
1647
+
1648
+ **Clicks inside the editor's frame.** A click on a tagged element selects its
1649
+ field in the editor. A ⌘-click (Ctrl-click on Windows and Linux) on a tagged
1650
+ element that is a link, or sits inside one, follows the link inside the frame
1651
+ instead, so an editor can move between pages. The hover label says so over a
1652
+ link. A link with no tag on or around it navigates as usual.
1653
+
1654
+ **After a save.** `<CapaOverlay />` re-renders the draft in place with
1655
+ `router.refresh()` and keeps the scroll position.
1656
+
1657
+ - The refresh runs in a React transition. While it is pending, the overlay
1658
+ makes a no-op state update every 300 ms. Under React 19.2 and Next 15.5, a
1659
+ draft refresh can otherwise stay suspended after its whole response has
1660
+ arrived, when the page sits inside a Suspense boundary that is already on
1661
+ screen (a root `loading.tsx` makes one), and show nothing until some other
1662
+ state update.
1663
+ - A refresh that has not landed after `refreshTimeoutMs` (10 seconds) reloads
1664
+ the page instead, which comes back to the same scroll position.
1665
+ - `refresh="reload"` reloads the page on every save, for a site that prefers
1666
+ it:
1667
+
1668
+ ```tsx
1669
+ <CapaOverlay adminOrigins={["https://app.capacms.com"]} refresh="reload" />
1670
+ ```
395
1671
 
396
- A server-side fix for this shipped briefly as #4628 (`instanceId` appended to
397
- every public row) and was reverted on 2026-09-20 under the public-surface
398
- freeze: `/v2/api`, `/v3/api`, the `/v1` public reads and `/files` answer the
399
- bytes the legacy API answers, and a row key that resolves the shadowing is
400
- planned for the next API version. **So a row from today's server carries no
401
- `instanceId`, and `row.id` may be a content value.**
1672
+ With `startOverlay`, `onRefresh` may return a promise that settles once the new
1673
+ draft is on screen. The overlay waits for it up to `refreshTimeoutMs`, and
1674
+ reloads if it rejects or is still pending. Without `onRefresh` every save
1675
+ reloads. The scroll position is kept every way.
402
1676
 
403
- The client already handles both: `instanceIdOf` prefers `instanceId` when a row
404
- has one and falls back to `id` when that is a UUID, so nothing here changes when
405
- the next version starts sending it.
1677
+ ### 3. Accept the preview link on any page
406
1678
 
407
- So **read `Page.ids`, not `row.id`**:
1679
+ The editor loads `<preview base URL><path>?capa-preview=<token>`. Honour the
1680
+ token on the request itself, not only on a dedicated route: rewrite any request
1681
+ carrying it to your draft route, verify it with `preview`, enable draft mode and
1682
+ redirect back.
1683
+
1684
+ ```ts
1685
+ // middleware.ts
1686
+ export function middleware(request: NextRequest) {
1687
+ // The editor's Published view: render this one request without draft mode.
1688
+ if (request.nextUrl.searchParams.get("capa-view") === "published") {
1689
+ const headers = new Headers(request.headers);
1690
+ const cookies = request.cookies.getAll().filter((c) => c.name !== "__prerender_bypass");
1691
+ headers.set("cookie", cookies.map((c) => `${c.name}=${encodeURIComponent(c.value)}`).join("; "));
1692
+ return NextResponse.next({ request: { headers } });
1693
+ }
1694
+ const token = request.nextUrl.searchParams.get("capa-preview");
1695
+ if (!token) return NextResponse.next();
1696
+ const target = request.nextUrl.clone();
1697
+ target.pathname = "/api/draft";
1698
+ target.search = `?token=${encodeURIComponent(token)}&path=${encodeURIComponent(request.nextUrl.pathname)}`;
1699
+ return NextResponse.rewrite(target);
1700
+ }
1701
+ ```
1702
+
1703
+ `/api/draft` is the route handler shown under "Open a draft in your own site"
1704
+ above, reading `token` and `path`.
1705
+
1706
+ **Cookies in a frame.** The editor frames your site from another site. A
1707
+ browser sends a cookie into a cross-site frame only when it is
1708
+ `SameSite=None; Secure`, and Safari 26.2 and later only when it is
1709
+ `Partitioned` too. Next sets the draft-mode cookie with neither `Partitioned`
1710
+ nor `Max-Age`, so it is dropped in Safari's frame and, everywhere else, opens
1711
+ every draft on the site until the browser closes. Pass Next's `cookies` to
1712
+ `createPreviewRoute` and `exitPreviewRoute` and they re-set it as
1713
+ `HttpOnly; Secure; SameSite=None; Partitioned; Path=/; Max-Age=3600`, and
1714
+ delete it the same way. `maxAge` changes the hour. In your own route, call
1715
+ `frameDraftCookie(cookies)` after `enable()` and `clearDraftCookie(cookies)`
1716
+ after `disable()`. A browser that drops the cookie still gets a fresh token on
1717
+ every preview load, which step 3 honours on any page.
1718
+
1719
+ Leave `redirect` out of both routes. Each then answers with its own 307,
1720
+ marked `X-Robots-Tag: noindex, nofollow`, `Referrer-Policy: no-referrer` (the
1721
+ token is in the URL) and `Cache-Control: private, no-store`. Next's
1722
+ `redirect()` cannot carry headers, so passing it keeps the old redirect.
1723
+
1724
+ **Mark drafts, and let Capa frame them.** `capaHeaders()` returns rules for
1725
+ `next.config`'s `headers()` that send `X-Robots-Tag: noindex, nofollow` and
1726
+ `Content-Security-Policy: frame-ancestors 'self' https://app.capacms.com` on a
1727
+ request carrying the draft cookie or a `capa-preview`, `capa-edit` or
1728
+ `capa-view` query, and on nothing else. A visitor's response, cached or not,
1729
+ is unchanged. Pass `adminOrigins` to name another admin, such as one running
1730
+ locally, and pass the same list to `createPreviewRoute`. A browser applies
1731
+ every CSP it is sent, so if your site sends its own `frame-ancestors` or
1732
+ `X-Frame-Options`, leave them off draft responses with
1733
+ `missing: [{ type: "cookie", key: DRAFT_COOKIE }]` on that rule.
1734
+ `capaMiddleware` marks its edit-mode responses noindex too, and
1735
+ `resolveEditRequest` returns the value as `robotsTag`.
1736
+
1737
+ ### Add preview to a live site without changing it
1738
+
1739
+ A live Next.js site that reads Capa with its own code keeps that code. Preview
1740
+ adds a branch that only draft mode switches on, and draft mode is off for
1741
+ every visitor, at build time and during ISR. Reading
1742
+ `(await draftMode()).isEnabled` leaves a static page static, so a visitor gets
1743
+ the same pages, headers and cache as before.
1744
+
1745
+ 1. **Read drafts in draft mode.** In the helper that fetches from Capa, before
1746
+ the existing fetch, read the same URL with a draft key and no cache. The
1747
+ data has the same shape, so every page renders unchanged.
1748
+
1749
+ ```ts
1750
+ // lib/capa-draft.ts
1751
+ import "server-only";
1752
+ import { draftMode } from "next/headers";
1753
+
1754
+ export async function isDraft(): Promise<boolean> {
1755
+ // draftMode() throws outside a request (generateStaticParams, some build steps).
1756
+ try { return (await draftMode()).isEnabled; } catch { return false; }
1757
+ }
1758
+
1759
+ // in the fetch helper, before the existing fetch, which stays as it is
1760
+ if (await isDraft()) {
1761
+ return fetch(`https://api.capacms.com/v2/api/${endpoint}${query}`, {
1762
+ headers: { "x-api-key": process.env.CAPA_DRAFT_KEY! },
1763
+ cache: "no-store",
1764
+ }).then(parse); // the helper's existing parse
1765
+ }
1766
+ ```
1767
+
1768
+ The draft key is any key of yours whose environment is not `production`
1769
+ (see Preview is a key, not a flag). Keep it server-only, never in a
1770
+ `NEXT_PUBLIC_` variable, and keep the branch in a `server-only` module: a
1771
+ helper a client component also imports would bundle it.
1772
+
1773
+ 2. **Add the preview and exit routes** with `createPreviewRoute({ draftMode,
1774
+ cookies })` and `exitPreviewRoute({ draftMode, cookies })`, as in the Quick
1775
+ start. Set `CAPA_API_URL` to `https://api.capacms.com` and `CAPA_KEY` to
1776
+ the production key the site already reads with, server-only: the routes
1777
+ check the editor's token with it.
1778
+
1779
+ 3. **Add the middleware behind a matcher**, so a visitor's request never runs
1780
+ it. Next reads `config` from the file itself, so write the matcher out:
1781
+
1782
+ ```ts
1783
+ // middleware.ts (proxy.ts on Next 16)
1784
+ import { NextResponse } from "next/server";
1785
+ import { capaMiddleware } from "@capacms/sdk/nextjs";
1786
+
1787
+ export const middleware = capaMiddleware({ NextResponse });
1788
+ export const config = {
1789
+ matcher: [
1790
+ { source: "/:path*", has: [{ type: "query", key: "capa-preview" }] },
1791
+ { source: "/:path*", has: [{ type: "query", key: "capa-edit" }] },
1792
+ { source: "/:path*", has: [{ type: "query", key: "capa-view" }] },
1793
+ { source: "/:path*", has: [{ type: "cookie", key: "__prerender_bypass" }] },
1794
+ ],
1795
+ };
1796
+ ```
1797
+
1798
+ 4. **Add `capaHeaders()`** to `next.config`'s `headers()`, as in the Quick
1799
+ start: drafts are marked noindex and only the Capa admin can frame them.
1800
+
1801
+ 5. **Start the overlay and tag fields in draft mode.** Load the overlay
1802
+ lazily from a small client component, so its code is not in a chunk a
1803
+ visitor downloads, and render that component from the root layout in
1804
+ draft mode only:
1805
+
1806
+ ```tsx
1807
+ // components/capa-preview.tsx
1808
+ "use client";
1809
+ import { lazy, Suspense } from "react";
1810
+
1811
+ const CapaOverlay = lazy(() =>
1812
+ import("@capacms/sdk/nextjs/overlay").then((m) => ({ default: m.CapaOverlay })),
1813
+ );
1814
+
1815
+ export default function CapaPreview({ adminOrigins }: { adminOrigins: string[] }) {
1816
+ return (
1817
+ <Suspense fallback={null}>
1818
+ <CapaOverlay adminOrigins={adminOrigins} />
1819
+ </Suspense>
1820
+ );
1821
+ }
1822
+
1823
+ // app/layout.tsx
1824
+ const draft = await isDraft();
1825
+ {draft ? <CapaPreview adminOrigins={["https://app.capacms.com"]} /> : null}
1826
+ ```
1827
+
1828
+ Tag an editable field with `{...capaAttrs({ id: entry.id }, "title", draft)}`.
1829
+ With `draft` false it returns `{}`, so a visitor's HTML gains no
1830
+ attribute. The field is its key in the entry, which is its namespace.
1831
+
1832
+ 6. **Route handlers that set their own `Cache-Control`** must send
1833
+ `private, no-store` in draft mode. Otherwise a CDN keeps a draft fetched by
1834
+ the editor's browser and serves it to everyone.
1835
+
1836
+ Do not call `editMode()`, `getCapaClient()` or `resolveEditRequest()` from a
1837
+ static or ISR page or layout. Each reads `headers()`, which makes every route
1838
+ that calls it dynamic: the HTML stays the same, but the site loses ISR and
1839
+ renders every view. `isDraft()` above is the static-safe check. The editor's
1840
+ Published view then shows the static page without the overlay.
1841
+
1842
+ In Capa, set the project's preview URL to the site's production origin, and
1843
+ give each model with a page a route pattern, or Preview has no link to open.
1844
+
1845
+ ### A save that shows nothing: a page transition that freezes the route
1846
+
1847
+ A site that animates page changes with framer-motion often wraps each page in
1848
+ a `FrozenRoute`, which pins Next's `LayoutRouterContext` so the page that is
1849
+ leaving keeps its content while it animates out:
1850
+
1851
+ ```tsx
1852
+ "use client";
1853
+
1854
+ import { useContext, useRef } from "react";
1855
+ import { LayoutRouterContext } from "next/dist/shared/lib/app-router-context.shared-runtime";
1856
+
1857
+ const FrozenRoute = ({ children }: { children: React.ReactNode }) => {
1858
+ const context = useContext(LayoutRouterContext);
1859
+ const frozen = useRef(context).current;
1860
+
1861
+ return (
1862
+ <LayoutRouterContext.Provider value={frozen}>
1863
+ {children}
1864
+ </LayoutRouterContext.Provider>
1865
+ );
1866
+ };
1867
+
1868
+ export default FrozenRoute;
1869
+ ```
1870
+
1871
+ The pinned context keeps the page as the frame first drew it. After a save,
1872
+ `router.refresh()` fetches the draft and nothing on screen changes, and no
1873
+ overlay setting can reach inside the freeze. Skip it in draft mode only. Give
1874
+ the page-transition wrapper a `live` prop:
1875
+
1876
+ ```tsx
1877
+ "use client";
1878
+
1879
+ import { usePathname } from "next/navigation";
1880
+ import { AnimatePresence, motion } from "framer-motion";
1881
+ import FrozenRoute from "./frozen-route";
1882
+
1883
+ const PageAnimatePresence = ({
1884
+ children,
1885
+ live,
1886
+ }: {
1887
+ children: React.ReactNode;
1888
+ /** Capa preview (draft mode only): no freeze, so an editor's save shows in place. */
1889
+ live?: boolean;
1890
+ }) => {
1891
+ const pathname = usePathname();
1892
+
1893
+ return (
1894
+ <AnimatePresence mode="wait">
1895
+ <motion.div className="flex flex-col flex-grow" key={pathname}>
1896
+ {live ? children : <FrozenRoute>{children}</FrozenRoute>}
1897
+ </motion.div>
1898
+ </AnimatePresence>
1899
+ );
1900
+ };
1901
+
1902
+ export default PageAnimatePresence;
1903
+ ```
1904
+
1905
+ and pass it from the root layout as a conditional spread, so a visitor's
1906
+ props, and the RSC payload that carries them, are exactly what they were:
1907
+
1908
+ ```tsx
1909
+ // app/layout.tsx
1910
+ const draft = await isDraft();
1911
+
1912
+ <PageAnimatePresence {...(draft ? { live: true } : {})}>
1913
+ {children}
1914
+ </PageAnimatePresence>
1915
+ ```
1916
+
1917
+ Writing `live={draft}` instead would send `live: false` to every visitor.
1918
+ To find the freeze, search the site for `LayoutRouterContext`.
1919
+
1920
+ ### The protocol
1921
+
1922
+ Every message is `{ source, v: 1, type, ...fields }`. `source` is
1923
+ `capa-admin` on the admin's messages and `capa` on the site's. The admin sends
1924
+ `hello`, `highlight { entryId, field }` (`entryId: ""` clears), `outline { on }`
1925
+ and `refresh`. The site answers `ready { path, entries }` after every hello and
1926
+ every navigation, `select { entryId, field }` on a click, and `hover`.
1927
+ `acceptMessage(event, allowedOrigins, parent)` is the site's filter, exported
1928
+ for your own tests.
1929
+
1930
+ The site also sends `visible { entryId, field }` while the page scrolls (at
1931
+ most every 150ms, and only when it changes): the tagged element at the centre
1932
+ of the viewport, chosen by `pickCentred`. At the very top of a page, where a
1933
+ heading can never reach the centre, it is the topmost visible element instead,
1934
+ and at the very bottom the bottommost. The editor's "Follow the page" scrolls
1935
+ the form to that field. An overlay older than 1.0.0-next.2 never sends it, and
1936
+ the editor then does not follow.
1937
+
1938
+ Following a link with ⌘-click sends no message of its own: the page the link
1939
+ leads to answers with `ready`, as after any navigation, so an editor of any
1940
+ version follows along.
1941
+
1942
+ Since 1.0.0-next.10 `ready` also lists `features: ["patch"]`, and the admin
1943
+ may send `patch { entryId, fields: [{ field, value, saved }] }` while an
1944
+ editor types: the unsaved text of plain text, number and option fields, and
1945
+ the saved value the page was rendered from. The overlay puts `value` into a
1946
+ tagged element's one text node while that text reads `saved` or what it last
1947
+ patched it to, so a field your site formats (a price, a date, Markdown) is
1948
+ left alone until the save re-renders it. It writes text, never markup, and
1949
+ only for the admin that said hello. Rich text, media and relations wait for
1950
+ the save, as before. An admin sends patches only when its project has live
1951
+ preview on.
1952
+
1953
+ ## Not yet
1954
+
1955
+ `--select-from-depth`, `capa convert-url`, GraphQL mutations, and `/api/`
1956
+ writes are not in this release.
1957
+
1958
+ ## The legacy `/v2/api` client
1959
+
1960
+ `@capacms/sdk` is the client for sites that read `/v2/api` today. It takes a
1961
+ legacy key (`pk_`, `sk_` or unprefixed) and the tenant's id, and refuses a
1962
+ `cap_` key where it is built, since `/v2` answers one as an invalid key: a
1963
+ `cap_` key reads `/api/`, with `createClient` from `@capacms/sdk/next`.
1964
+ Besides reads, it schedules publishes, arranges the admin's workspaces and
1965
+ entry layouts, and manages webhook endpoints.
1966
+
1967
+ ```ts
1968
+ import { createClient } from "@capacms/sdk";
1969
+ import type { BlogPost } from "./capa-types"; // written by capa-codegen
1970
+
1971
+ const capa = createClient({
1972
+ baseUrl: process.env.CAPA_API_URL!,
1973
+ apiKey: process.env.CAPA_KEY!, // a pk_ key, not a cap_ key
1974
+ tenantId: process.env.CAPA_TENANT_ID!,
1975
+ });
1976
+
1977
+ const page = await capa.listContent<BlogPost>("blog_post", { limit: 10 });
1978
+ const one = await capa.findOne<BlogPost>("blog_post", { handle: "hello" });
1979
+ ```
1980
+
1981
+ ### An entry's id: read `Page.ids`
1982
+
1983
+ A `/v2/api` row puts the model's own fields at its top level, so a model
1984
+ with a field named `id` replaces the entry's id in the row, and the same goes
1985
+ for `title` and `tags`. Many models have one: every Shopify model names a
1986
+ field `id`. So read `Page.ids`, not `row.id`:
408
1987
 
409
1988
  ```ts
410
1989
  const page = await capa.listContent("shopify_page");
411
- page.ids[0] // the instance UUID when the row has one to give
1990
+ page.ids[0] // the entry's UUID when the row has one to give
412
1991
  page.data[0].id // the model's OWN id field whenever one exists
413
1992
  ```
414
1993
 
415
- `Page.ids` is `string | null` per row: `null` means the model shadows `id` and
416
- the server sent nothing else to read it from, which is the state of every server
417
- today. `getContentById` throws on a non-UUID rather than spending a request on a
418
- guaranteed 400.
419
-
420
- `search()` is the same story. `/v2/api/search?extended=true` hoists fields the
421
- same way, so `SearchHit.id` is read through the same `instanceIdOf` and has the
422
- same `string | null` type.
1994
+ `Page.ids` is `string | null` per row, in the order of `data`: `null` means
1995
+ the model shadows `id` and the row carries nothing else to read the entry's
1996
+ id from. `instanceIdOf(row)` reads one row the same way, and prefers an
1997
+ `instanceId` when a row carries one. `getContentById` throws for an id that is
1998
+ not a UUID rather than spending a request on a certain 400. `search()` reads
1999
+ its hits the same way, so `SearchHit.id` is `string | null` too. `/api/` has
2000
+ no such collision: an entry's system keys sit beside its `fields`.
423
2001
 
424
- ## Preview is a key, not a flag
2002
+ ### Preview is a key, not a flag
425
2003
 
426
- Capa gates unpublished content on the API key's `environment` column, not on
427
- anything in the request (`routes/v2/api.ts:203`). There is no `?preview=true`, so
2004
+ Capa shows unpublished content to a key whose environment is not
2005
+ `production`, whatever the request says. There is no `?preview=true`, so
428
2006
  preview is a second client:
429
2007
 
430
2008
  ```ts
@@ -432,67 +2010,91 @@ const capa = createClient({ ...cfg, apiKey: PUBLISHED_KEY });
432
2010
  const preview = createClient({ ...cfg, apiKey: PREVIEW_KEY });
433
2011
  ```
434
2012
 
435
- The comparison is exact and case-sensitive against a free-text column, so **any**
436
- environment that is not literally `production` returns drafts — including a typo
437
- like `Production`. And a client cannot find out which it holds: `apiKeyEnvironment`
438
- is consumed internally and returned by no endpoint, so a site handed a `draft`
439
- key serves unpublished content publicly with no way to detect it.
440
-
441
- ## Not included
442
-
443
- **Caching.** Every framework does it differently and owning it means
444
- reimplementing all of them badly. Plain `fetch` returning plain data lets Next's
445
- fetch cache and React Router loaders work natively. What the host cannot know is
446
- which surrogate keys a response carries, so those are surfaced as
447
- `Page.cacheTags` for CDN purging.
2013
+ The comparison is exact and case-sensitive, so **any** environment that is
2014
+ not literally `production` returns drafts, a typo like `Production`
2015
+ included. And a `/v2` client cannot find out which it holds: no `/v2`
2016
+ endpoint reports a key's environment, so a site handed a `draft` key serves
2017
+ unpublished content publicly with no way to detect it. `/api/` reports it:
2018
+ `capa.me()` on `@capacms/sdk/next` answers the key's `environment`.
448
2019
 
449
- **`getBySlug`.** Capa has no slug convention — the field is `handle` on
450
- `shopify_page`, `product_handle` on `judge_me_review`, `bloghandle` on
451
- `shopify_article`, and nothing marks one as *the* slug. `findOne(ns, {handle})`
452
- is explicit instead. A slug marker belongs on the model, in Capa.
2020
+ ### Codegen
453
2021
 
454
- **Content writes.** `/v2/agent/model-instances` exists and an API key reaches
455
- it, so an SDK that can silently publish a draft is now technically possible.
456
- That wants a deliberate surface of its own rather than another method quietly
457
- added to the client, so it is absent rather than half-built. The one exception
458
- is `scheduledActions`, below, and the exception is narrow on purpose: a schedule
459
- is a request that leaves a row you can read, show and cancel. Publishing *now*
460
- leaves the caller holding the only copy of what happened.
2022
+ ```sh
2023
+ CAPA_API_URL=... CAPA_KEY=pk_... CAPA_TENANT_ID=... capa-codegen --out src/capa-types.ts
2024
+ ```
461
2025
 
462
- This section used to say the SDK could not write at all, because `verifyApiKey`
463
- guarded five route files carrying 15 GETs and no write verb. That stopped being
464
- true with the agent surface (`#383`) and `tenant_api_keys.permission` becoming a
465
- real control (`#4646`). Two things write today, and both touch arrangement
466
- rather than content:
2026
+ Without `--graphql`, `capa-codegen` reads the legacy `/v2` schema, which
2027
+ takes a `pk_` key and `CAPA_TENANT_ID`. A `cap_` key, which `/v2` refuses,
2028
+ writes the same interfaces from the key's GraphQL schema, with no tenant id,
2029
+ and so does `--schema <file>`, a schema `capa-codegen --graphql --save-schema`
2030
+ wrote. GraphQL does not say three things, so those types say less: every
2031
+ field is optional, an enum is a `string`, and a field GraphQL leaves out is
2032
+ `unknown`. There is no `CAPA_SCHEMA_CHECKSUM`, since that is the `/v2`
2033
+ schema's checksum. Where the API does not serve GraphQL, use a `pk_` key. It
2034
+ reads `.env.local` and `.env` too, as `next dev` does.
2035
+
2036
+ Exit codes: `0` wrote or already current, `1` failed, `2` `--check` found a
2037
+ diff. `--check` is the CI mode: it fails when the committed file is stale.
2038
+
2039
+ **Commit the output.** Generating at install needs credentials during
2040
+ `npm install`, which breaks CI images and Docker builds, and generating at
2041
+ build time makes every build depend on the network. A committed file also
2042
+ turns "a model changed, so the build fails" into a reviewable diff instead of
2043
+ a wall of `tsc` errors.
2044
+
2045
+ With a `pk_` key, re-running when nothing changed costs one conditional
2046
+ request that returns no body: codegen stamps the `/v2` schema's checksum in
2047
+ the file and fetches the types only when the schema moved. With `--graphql`,
2048
+ a `cap_` key or `--schema` there is no checksum, so every run reads the whole
2049
+ schema again: one full introspection request, or the file. Models are written
2050
+ in the order of their interface names, so renaming a model's display name
2051
+ does not reorder the file.
2052
+
2053
+ ### What it leaves out
2054
+
2055
+ **Caching.** Every framework caches differently, so the client returns plain
2056
+ data from plain `fetch`, and Next's fetch cache and React Router loaders work
2057
+ with it as they are. What your host cannot know is which surrogate keys a
2058
+ response carries, so `Page.cacheTags` holds them for purging a CDN.
2059
+
2060
+ **`getBySlug`.** Capa has no slug convention: the field is `handle` on
2061
+ `shopify_page`, `product_handle` on `judge_me_review` and `bloghandle` on
2062
+ `shopify_article`, and nothing marks one as the slug. `findOne(ns, { handle })`
2063
+ says which.
2064
+
2065
+ **Content writes.** The client writes no entry content. It schedules
2066
+ publishes (`scheduledActions`, below), a request that leaves a row you can
2067
+ read, show and cancel, and it writes arrangement, which touches no entry:
467
2068
 
468
2069
  ```ts
469
- await capa.workspaces.apply(id, { tree }); // the admin rail (0h, 0t)
470
- await capa.models.setLayout(id, layout); // the entry editor (0m)
2070
+ await capa.workspaces.apply(id, { tree }); // the admin's left rail
2071
+ await capa.models.setLayout(id, layout); // the entry editor
471
2072
  await capa.models.setLayout(id, null); // back to the linear editor
472
2073
  ```
473
2074
 
474
- Reading has two shapes. `models.getLayout(id)` is the document or null;
475
- `models.getLayoutInfo(id)` is the same read with the model's `embedByDefault`
476
- beside it, which is what decides a relation field with no `display` on its
477
- layout node (section 0m.8, and it applies in linear mode too):
2075
+ ### Workspaces and entry layouts
2076
+
2077
+ Reading a layout has two shapes. `models.getLayout(id)` is the document or
2078
+ null; `models.getLayoutInfo(id)` is the same read with the model's
2079
+ `embedByDefault` beside it, which decides how a relation field with no
2080
+ `display` renders, in linear mode too:
478
2081
 
479
2082
  ```ts
480
2083
  const { layout, embedByDefault } = await capa.models.getLayoutInfo(id);
481
2084
  ```
482
2085
 
483
- A workspace `tree` is a flat `WorkspaceDocNode[]` since 0t: one list of folders
484
- the customer named, each holding any mix of `{model}`, `{instance}`,
485
- `{media_folder}` and further folders. The 0h shape, `{ model, content, media }`,
486
- is still accepted for one release and lands in folders called Models, Content
487
- and Media.
2086
+ A workspace `tree` is a flat `WorkspaceDocNode[]`: one list of folders you
2087
+ named, each holding any mix of `{model}`, `{instance}`, `{media_folder}` and
2088
+ further folders. The older shape, `{ model, content, media }`, is still
2089
+ accepted and lands in folders called Models, Content and Media.
488
2090
 
489
2091
  `workspaces.*` needs a key with `write`; `models.setLayout` needs one with
490
- `agent`, because it writes a MODEL and that is the gate every other model write
491
- asks for. `models.getLayout` needs only `read`. A refused document comes back as
492
- `CapaError` carrying the API's own `{ error, path }`, where `path` names the
493
- node to fix. `apps/api/LAYOUT.md` has the rules and a worked example.
2092
+ `agent`, because it writes a model and that is the permission every model
2093
+ write asks for. `models.getLayout` needs only `read`. A refused document comes
2094
+ back as `CapaError` carrying the API's own `{ error, path }`, where `path`
2095
+ names the node to fix.
494
2096
 
495
- ## Scheduling a publish
2097
+ ### Scheduling a publish
496
2098
 
497
2099
  ```ts
498
2100
  const { batchId, resolved, actions, replaced } = await capa.scheduledActions.create({
@@ -503,10 +2105,9 @@ const { batchId, resolved, actions, replaced } = await capa.scheduledActions.cre
503
2105
  });
504
2106
  ```
505
2107
 
506
- **Instance targets only, from a key.** A `{ type: "model" }` target needs the
507
- `model:publish` permission, and no API key bundle carries it in phase 1, so the
508
- server answers 403 whatever the key. Scheduling a schema publish is an admin
509
- session job until a bundle grants it.
2108
+ **Entry targets only, from a key.** A `{ type: "model" }` target needs the
2109
+ `model:publish` permission, which no API key carries, so the server answers
2110
+ 403 whatever the key. Scheduling a model publish is done in the Capa admin.
510
2111
 
511
2112
  **Send a wall clock and a zone, not an instant.** `wallTime` carrying a `Z` or a
512
2113
  `+02:00` is refused, here and by the server, because the two together are the
@@ -554,18 +2155,17 @@ right now and `cancel` answers 409 `already_running`. `resultVersionId` on a
554
2155
  `done` row is the version that went live.
555
2156
 
556
2157
  Reading needs a `read` key; every write here needs `instance:publish`, which the
557
- `write`, `delete` and `agent` bundles carry. A refusal is a `CapaError` with the
558
- API's own `{ error, code }`. The operator's side of all of this, including what
559
- to do when an action fails at 3am, is `docs/PUBLISHING.md`.
2158
+ `write`, `delete` and `agent` keys carry. A refusal is a `CapaError` with the
2159
+ API's own `{ error, code }`.
560
2160
 
561
- ## Webhooks
2161
+ ### Webhooks
562
2162
 
563
2163
  Two halves that do not need each other. The verifier is clientless, so a
564
2164
  receiver imports one function and nothing else. The resource manages endpoints,
565
2165
  and it is the only thing in this SDK that needs a session token rather than an
566
2166
  API key.
567
2167
 
568
- ### Verifying a delivery
2168
+ #### Verifying a delivery
569
2169
 
570
2170
  ```ts
571
2171
  import { verifyWebhookSignature } from "@capacms/sdk";
@@ -604,12 +2204,12 @@ During the 24 hours after a rotation Capa sends two `v1=` entries, the new
604
2204
  secret first. Either one verifies, so a receiver can be updated any time inside
605
2205
  that window without dropping a request.
606
2206
 
607
- ### Managing endpoints
2207
+ #### Managing endpoints
608
2208
 
609
2209
  ```ts
610
2210
  const capa = createClient({
611
- baseUrl: process.env.CAPA_BASE_URL!,
612
- apiKey: process.env.CAPA_API_KEY!,
2211
+ baseUrl: process.env.CAPA_API_URL!,
2212
+ apiKey: process.env.CAPA_KEY!,
613
2213
  tenantId: process.env.CAPA_TENANT_ID!,
614
2214
  accessToken: sessionJwt, // from POST /v2/user/login
615
2215
  });
@@ -624,19 +2224,20 @@ const { secret, ...endpoint } = await capa.webhooks.endpoints.create({
624
2224
 
625
2225
  `createClient` still needs `apiKey` and `tenantId` to construct at all, so a
626
2226
  script that only ever calls `webhooks.*` has to pass something non-empty for
627
- both. Any placeholder will do, because no webhook call reads either one.
2227
+ both. Any legacy-looking placeholder will do, because no webhook call reads
2228
+ either one.
628
2229
 
629
2230
  **The tenant comes from the session, not from `tenantId`.** These routes read
630
2231
  the current tenant off the logged-in user's token, so the `tenantId` you passed
631
2232
  to `createClient` is ignored for every `webhooks.*` call: a client built with one
632
2233
  `tenantId` operates on whatever tenant that user is currently on.
633
2234
 
634
- **`accessToken`, not `apiKey`.** The webhook routes have no API key mount at
635
- all (publishing spec D8): a key that can add an endpoint can forward every
636
- content change in the tenant to a URL of its choosing, and a key is a string in
637
- a config file nobody rotates. So these routes want a logged-in person, and every
638
- `webhooks.*` method throws `@capacms/sdk: webhooks need accessToken; API keys
639
- cannot manage endpoints.` before sending anything when the token is absent.
2235
+ **`accessToken`, not `apiKey`.** The webhook routes take no API key at all: a
2236
+ key that can add an endpoint can forward every content change in the project
2237
+ to a URL of its choosing, and a key is a string in a config file nobody
2238
+ rotates. So these routes want a logged-in person, and every `webhooks.*`
2239
+ method throws `@capacms/sdk: webhooks need accessToken; API keys cannot manage
2240
+ endpoints.` before sending anything when the token is absent.
640
2241
 
641
2242
  The rest of the resource:
642
2243
 
@@ -659,9 +2260,9 @@ await capa.webhooks.deliveries.redeliver(deliveryId); // same event id
659
2260
  ```
660
2261
 
661
2262
  `test` answers `{ eventId }`, and that value is an **opaque request id**: the
662
- event that actually arrives carries a different `id` (`evt_` plus the outbox row
663
- id), so there is nothing to correlate on. Read `deliveries(id)` and take the
664
- newest `webhook.test` row to see what the test did.
2263
+ event that actually arrives carries a different `id` (`evt_` plus another
2264
+ id), so there is nothing to correlate on. Read `deliveries(id)` and take the newest
2265
+ `webhook.test` row to see what the test did.
665
2266
 
666
2267
  `resume` without `backfill` is deliberately a two-step: it enables the endpoint
667
2268
  and tells you how many events it missed, so you can show a person
@@ -669,10 +2270,7 @@ and tells you how many events it missed, so you can show a person
669
2270
  Call it again with `{ backfill: true, since }` if they say yes.
670
2271
 
671
2272
  Reads need `webhook:read`, writes need the `webhook:*` write actions, and
672
- `revealSecret` needs `secret:reveal`, which no role bundle holds: it is an owner
673
- or an explicit override, and every call leaves an audit row whether it was
674
- allowed or denied. A server without `CAPA_SECRET_KEY` set answers `503` with
675
- code `webhooks_not_configured` to every write while reads keep working.
676
-
677
- The operator's side of all of this, including the retry ladder, auto-pause, the
678
- SSRF rules and how to run a receiver locally, is `docs/WEBHOOKS.md`.
2273
+ `revealSecret` needs `secret:reveal`, which no role holds by default: it is an
2274
+ owner or an explicit grant, and every call leaves an audit row whether it was
2275
+ allowed or denied. A server without webhook secrets configured answers `503`
2276
+ with code `webhooks_not_configured` to every write while reads keep working.