# Pages

Source: https://capacms.com/docs/preview/pages

Which of your pages read which entries, what Capa suggests about them, and how preview opens a draft.

Capa stores content. Your site renders pages. Until now nothing connected the
two, so Capa could not answer the question editors ask most often before
changing anything:

> What breaks if I unpublish this?

This page is how Capa answers it, and how an editor opens an unpublished draft
in your own site.

Start with [API reference](https://capacms.com/docs/api) for how a `/api/` request is shaped and
[Keys and scopes](https://capacms.com/docs/api/authentication) for how to mint a key. These routes need the
`instance:read` scope, the same one the entry routes need.

| Route                       | What it answers                                                                     |
| --------------------------- | ----------------------------------------------------------------------------------- |
| `GET /api/pages`            | every page Capa knows about, busiest first                                          |
| `GET /api/pages?entry={id}` | only the pages that read one entry                                                  |
| `GET /api/pages/{page}`     | one page: its entries, its queries, its traffic and its [suggestions](#suggestions) |
| `GET /api/preview`          | is this preview token good, and what does it open                                   |

## What a page is

A page is a string that starts with `/`. Capa learns about it in two ways, and a
page can arrive by either or by both.

**Declared.** A model carries a route: `/blog/[slug]` for a collection whose
entries each get a page, `/pricing` for a single page. You set this in the Capa
admin, on the model. A declared page exists whether or not anyone has ever
loaded it, which is the point: a route you just set up should show up
immediately, not after the first visitor.

**Observed.** A read arrived carrying a `Capa-Page` header (see
[Entries](https://capacms.com/docs/api/entries#telling-capa-which-page-you-are-rendering)). An observed
page exists whether or not any model declares it, which is also the point: most
sites have pages Capa knows nothing about, and the first useful thing Capa can
say is "here they are".

Only `/api/entries` reads are recorded. GraphQL reads and the legacy `/v2/api`
and `/v3/api` reads ignore the header, so a page that reads only through them
never shows up as observed.

The two join on the string itself, so `/blog/[slug]` declared and `/blog/[slug]`
observed are one page, reported as `kind: "both"`. That is the state a fully
wired site reaches. Until then you will see `declared` pages with no traffic
(nothing is sending the header yet) and `observed` pages with no route (your
site has pages Capa does not model).

### The grammar

A **route** you declare on a model is strict:

* starts with `/`; `/` alone is the home page
* lowercase letters, digits and hyphens between the slashes
* at most one `[name]` segment, named with letters, digits and underscores
* no trailing slash, except the root
* at most 200 characters, and no `..`, `?` or `#`

`/blog/[slug]`, `/pricing`, `/docs/[pageId]`, `/` are routes. `/Blog` is not
(a URL path is compared byte for byte, and `/Blog` and `/blog` are two pages to
every cache in the world). `/blog/[year]/[slug]` is not: a model has one slug
field, so a route has one dynamic segment.

A **page identity** on the wire, which is what `Capa-Page` carries and what
`{page}` in the path names, is looser: it accepts the concrete path your site
actually served (`/blog/hello`) as well as the pattern. Refusing the concrete
form would silently discard every site that reports the URL it is rendering.

### The slug field

A route with a `[name]` segment needs to know which field holds the value, so
the model also carries a slug field. It must be a field the model actually has,
and its type must be one a URL segment can hold: a string, an enum, or a number.
A rich-text or relation field cannot be a slug, and Capa refuses to set one
rather than producing links that do not work.

Setting the route and the slug field is what makes Preview able to say which
page an entry is published at.

## `GET /api/pages`

```bash
curl https://cdn.capacms.com/api/pages \
  -H "x-api-key: $CAPA_KEY" \
  -H 'Capa-Version: 2026-10-01'
```

```json
{
  "data": [
    {
      "id": "/blog/[slug]",
      "pattern": "/blog/[slug]",
      "kind": "both",
      "models": [
        { "id": "…", "namespace": "articles", "name": "Article" }
      ],
      "reads30d": 812,
      "lastReadAt": "2026-09-21T09:14:02.000Z",
      "slugField": "slug"
    },
    {
      "id": "/pricing",
      "pattern": "/pricing",
      "kind": "observed",
      "models": [{ "id": "…", "namespace": "landing", "name": "Landing page" }],
      "reads30d": 96,
      "lastReadAt": "2026-09-21T08:50:11.000Z",
      "slugField": null
    }
  ],
  "meta": {
    "version": "2026-10-01",
    "contract": 1,
    "environment": "production",
    "requestId": "req_…",
    "since": "2026-08-23T00:00:00.000Z",
    "cap": 500,
    "truncated": false,
    "entry": null,
    "entrySince": null,
    "insights": []
  }
}
```

| Field           | Read it as                                                                                                                                |
| --------------- | ----------------------------------------------------------------------------------------------------------------------------------------- |
| `id`, `pattern` | the page string. They are equal: the string is the identity                                                                               |
| `kind`          | `declared`, `observed`, or `both`                                                                                                         |
| `models`        | the models this page reads: each one's `id`, `namespace` and `name`, the name the admin shows. A model deleted since the read is left out |
| `reads30d`      | origin reads in the window `meta.since` opens. See [what is not counted](#what-is-not-counted)                                            |
| `lastReadAt`    | the most recent read, or `null` for a declared page nobody has loaded                                                                     |
| `slugField`     | the field the route's `[name]` segment is filled from, or `null`                                                                          |

**The list is not paged.** A project has tens or hundreds of pages, not
thousands, and the list is a whole-site view whose first use is "show me all of
them, sorted by traffic". Paging it would mean walking a cursor before you could
sort, since the ordering is by a number the last page can change. It is capped
at `meta.cap` instead, and `meta.truncated` says whether the cap cut anything.

Ordering is busiest first, then by page string, so two calls a second apart
return the same order and you can diff them.

### `?entry={id}`: which pages read this entry

```bash
curl 'https://cdn.capacms.com/api/pages?entry=00000000-0000-4000-8000-000000000021' \
  -H "x-api-key: $CAPA_KEY"
```

Returns only the pages that read that entry, each row carrying an extra
`entryReads` count. This is the answer to "what breaks if I unpublish this?", and
it is what "appears on 4 pages" in the Capa admin is reading.

A row is otherwise identical to the same row in the unfiltered list, `reads30d`
and all, so the two lists never disagree about a page's traffic depending on how
you arrived at it.

`entryReads` covers a **shorter window** than `reads30d`: seven days, reported as
`meta.entrySince`. Entry ids live only on the raw read rows and those are kept
for seven days (see [retention](#retention)). A count that is right for seven
days beats one that is wrong for thirty.

An entry no page has read, and an id that never existed, both answer with an
empty list. Telling them apart would make this route a way to find out which ids
exist.

## `GET /api/pages/{page}`

The page identity contains slashes, so it is URL-encoded whole into one path
segment:

```bash
curl "https://cdn.capacms.com/api/pages/$(printf %s '/blog/[slug]' | jq -sRr @uri)" \
  -H "x-api-key: $CAPA_KEY"
```

```json
{
  "data": {
    "page": "/blog/[slug]",
    "kind": "both",
    "models": [{ "id": "…", "namespace": "articles", "name": "Article" }],
    "reads30d": 812,
    "readsByDay": [{ "day": "2026-09-20T00:00:00.000Z", "reads": 31 }],
    "entries": [
      {
        "id": "00000000-0000-4000-8000-000000000021",
        "modelId": "…",
        "namespace": "articles",
        "title": "Alpha ships today",
        "reads": 44
      }
    ],
    "queries": [
      {
        "modelId": "…",
        "namespace": "articles",
        "selectText": "title,slug",
        "url": "/api/entries/articles?select=title,slug&limit=10&sort=-publishedOn",
        "reads": 812,
        "lastAt": "2026-09-21T09:14:02.000Z",
        "keyIds": ["…"],
        "selection": {
          "model": "…",
          "system": ["id", "model", "status"],
          "fields": [{ "name": "title" }, { "name": "slug" }]
        },
        "selectionError": null
      }
    ],
    "since": "2026-08-23T00:00:00.000Z",
    "lastReadAt": "2026-09-21T09:14:02.000Z",
    "slugField": "slug",
    "insights": []
  },
  "meta": { "…": "…" }
}
```

`entries` is the last seven days and is capped, for the reason `entryReads` is:
the ids are only on the raw rows. An entry whose `modelId` and `namespace` are
empty strings is one Capa no longer holds a row for. It is kept in the list on
purpose, because "this page reads something that is gone" is the most useful
thing the list can say. `title` is the title stored on the entry's own row,
which many entries do not have, so a `null` title on its own means nothing
more than that.

`queries` is one row per distinct `(model, select)` this page asked for, which is
how you find a page that is fetching far more than it renders.

| Field            | Read it as                                                                                                                                                                     |
| ---------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| `url`            | the request this group most often made, with its filters, sort and limit. `null` once the group has been folded into the daily rollup, which keeps counts rather than requests |
| `selection`      | the `select` parsed into the Selection IR against the model's **current** schema, so you do not have to parse it yourself                                                      |
| `selectionError` | `{ code, message }` when the stored `select` no longer parses, and `null` otherwise. Exactly one of the two is set                                                             |

A `selectionError` is usually not a bug. A query recorded last week naming a
field the project has since removed is exactly the drift this screen exists to
show, so it is reported rather than swallowed.

`lastReadAt` on the detail is the newest read of the page in the window, or
`null` when nothing has read it. A folded row knows the day and not the instant,
so it claims the day's end: the latest moment the read could have happened, and
never a future one.

A page nobody has declared and nobody has read answers `404 page_not_found`.

## Suggestions

Capa runs a handful of rules over a page's own reads and returns what they
found as `insights`.

**They are computed on the API, and that is the point.** Three clients want this
answer: the Capa admin, `@capacms/sdk` and `@capacms/mcp`, which has no dependencies
and cannot parse a `select` at all. A second implementation of "this page
over-fetches" would drift from the first the moment a threshold moved, so the
rules run in one place and every client renders one answer.

`GET /api/pages/{page}` carries every insight about that page in
`data.insights`. `GET /api/pages` carries **only** `unused` insights, and
carries them in `meta.insights`: "no page reads this entry" is a claim about
every page at once, so hanging it off one row would invite the reading "this
page does not read it", which is true of nearly everything. (`GET /v2/pages`
answers a naked object and carries the same rows at its top level.)

```json
{
  "kind": "overfetch",
  "severity": "warn",
  "title": "This page asks for the whole entry.",
  "detail": "This read sends no select, so every field comes back and each relation is expanded as well. Naming the fields the page renders stops the relation rows being fetched at all.",
  "rewrite": { "select": "title,slug,body,summary,views,published" },
  "evidence": { "fieldCount": 9, "relationCount": 1, "reads": 812 },
  "modelId": "…",
  "queryKey": "…:"
}
```

| Field      | Read it as                                                                                                                             |
| ---------- | -------------------------------------------------------------------------------------------------------------------------------------- |
| `kind`     | which rule found it: `overfetch`, `fanout`, `cache`, `drift`, `unused`                                                                 |
| `severity` | `warn` is worth acting on, `info` is worth knowing                                                                                     |
| `title`    | one sentence, present tense. The numbers are in `evidence`, not hidden in here                                                         |
| `detail`   | why it matters, in one or two sentences                                                                                                |
| `rewrite`  | the copyable fix, when the rule can build one. Absent when it cannot, because a suggestion nobody can act on is noise wearing a button |
| `evidence` | every number the rule used, so you can check the claim rather than trust it                                                            |
| `modelId`  | the model it is about, when it is about one                                                                                            |
| `queryKey` | the `queries[]` row it is about: `${modelId}:${selectText ?? ""}`                                                                      |

Insights are sorted `warn` first, then in the kind order of the table below,
which is the order you can act in: a `select` is a one-line change, a fan-out is
a refactor of one component, a cache miss is a header, drift is a command, and
unused entries are a decision about content.

### `overfetch`

**Fires when** a query sends no `select` at all, or `*`, on a model with more
than 8 fields **or** any relation field.

`warn` when the model has a relation, `info` when it is only wide. The relation
is what makes it worse than wasted bytes: with no `select` every relation
expands at up to 100 rows per hop, so one entry read can become a hundred rows
of a second model.

**Rewrite:** `select` naming the model's scalar fields in schema order, rendered
canonically so it re-parses to itself. Relations are left out; that is the
change. There is no rewrite when the model has nothing but relations, because a
`select` naming only the system keys would be a worse read, not a better one.

**Evidence:** `fieldCount`, `relationCount`, `reads`.

This is a v1 approximation and is meant to be. The real question is which fields
the component renders, which needs a usage signal the SDK does not send yet.
"Asked for everything" is the common case and the one worth fixing first, so a
page that sent a considered `select` is left alone.

### `fanout`

**Fires when**, over the last 7 days, a page made single-entry reads
(`/api/entries/{ns}/{id}`) to one model covering at least 3 distinct entries,
and its single reads were at least 3 times its list reads to that model. Always
`warn`.

A page with **no** list reads passes the ratio, which is correct: fetching five
entries by id and never listing them is the clearest form of the pattern.

**Rewrite:** `filter` with `filter[id][in]` set to up to 20 of the ids, and
`url`, the whole call built from it with the `select` those reads most often
sent. The ids are read from the request path rather than from the entries a read
returned, so a page fetching ids that no longer exist still shows up.

**Evidence:** `singleReads`, `listReads`, `distinctEntries`, `sampleEntryIds`
(at most 5).

### `cache`

**Fires when**, over the page's 10 busiest URLs in the last 7 days,
`(miss + pass) / total` is above 0.2 with at least 20 edge lines. Always `warn`.

Misses and passes are counted together because they cost the same thing, an
origin request, and `detail` says which of the two dominated and what that
usually means: a pass is the edge being told not to store the response, which is
what a per-user request header or a development key produces; a miss is the edge
having nothing stored, which is what a publish-all purge or a short surrogate
lifetime leaves behind.

**Evidence:** `total`, `hit`, `miss`, `pass`, `dominantState`, `dominantReason`.

`dominantReason` is Fastly's `response_reason`, which is the **HTTP reason
phrase** ("OK", "Not Found") rather than a cache reason. It is carried because
it is what the edge logged; the explanation in `detail` is derived from the
cache state, which is the field that carries that meaning.

A page with no edge lines at all gets no insight rather than a bad ratio. If the
edge log cannot be read, an `info` insight with an empty `evidence` says the
cache data is not available, which is visibly different from "this page caches
fine".

### `drift`

**Fires when** the newest `Capa-Schema` a page has sent is not the project's
current schema checksum. Always `warn`.

Both halves have to be present. A page that never sent the header says nothing
about its build, and that is not evidence of a stale one.

**Evidence:** `seenChecksum`, `currentChecksum`, `lastSeenAt`.

See [`Capa-Schema`](#capa-schema) below for how the stamp gets there.

### `unused`

**Fires when** entries have been read by no page in 30 days **and** last edited
more than 90 days ago, grouped by model. Always `info`, and only on the list.

Computed by a nightly job rather than on request: the question is an anti-join
between everything a project owns and every entry id its pages have read, and
asking it on a page load would make the cheapest screen the most expensive one.

**A project with no observed page gets no `unused` insights at all.** Without
that gate every entry of every uninstrumented project would qualify, which is a
description of the instrumentation and not a finding.

**Evidence per model:** `count`, `sampleEntryIds` (at most 5, the stalest ones),
`namespace`.

### What is not a rule

A legacy `depth=2` call that should become a `select` with one expansion is in
the plan and is **not built**, because it cannot be: `depth` is a legacy `/v2/api`
parameter and `/api/` has no such thing, so no read on this surface could ever
trigger it. It belongs to a `capa convert-url` command, which is not built yet.

## `Capa-Schema`

Send the checksum of the schema your code was generated from and Capa can tell a
site built against the current models from one built against an older set:

```
GET /api/entries/articles?limit=3
Capa-Page: /blog/[slug]
Capa-Schema: 7199153b8f2bd4cf
```

The value is the `checksum` `GET /v2/schema` returns, which is also its `ETag`
and which `capa-codegen` writes into your generated types as
`CAPA_SCHEMA_CHECKSUM`. With `@capacms/sdk/next` you pass it once:

```ts
import { CAPA_SCHEMA_CHECKSUM } from "./capa-types";

const capa = createClient({ baseUrl, apiKey, version, schemaChecksum: CAPA_SCHEMA_CHECKSUM });
```

It obeys the same three rules as `Capa-Page`:

1. **A value Capa cannot parse is ignored, never refused.** The shape is 8 to 64
   lower-case hex characters. Anything else is dropped and the request is served
   exactly as if the header had never been sent.
2. **It never varies the response.** Not in `Vary`, not echoed, and the body, the
   `ETag` and the `Surrogate-Key` are byte for byte what they would have been
   without it.
3. **It is recorded only alongside `Capa-Page`.** The stamp is stored on the page
   read row, so a request that names no page records nothing.

Why it is worth sending: a site that has never been rebuilt keeps issuing
perfectly valid requests, so a stale build is otherwise invisible. This is the
only signal that can see it.

## Preview

An editor presses Preview in the Capa admin and gets a link to **your** site:

```
https://yoursite.example.com/blog/alpha-ships-today?capa-preview=<token>
```

The base of that URL is the preview URL set on the project in the Capa admin.
Without one the editor sees the path but no link to open.

Your site takes the token and asks Capa whether it is good:

```bash
curl 'https://cdn.capacms.com/api/preview?token=<token>' \
  -H "x-api-key: $CAPA_KEY"
```

```json
{
  "data": {
    "entryId": "00000000-0000-4000-8000-000000000021",
    "modelId": "…",
    "namespace": "articles",
    "path": "/blog/alpha-ships-today",
    "expiresAt": "2026-09-21T10:14:02.000Z"
  },
  "meta": { "…": "…" }
}
```

A good token means: enable your framework's draft mode and render `path`. With
`@capacms/sdk/nextjs` that is a six-line route handler.

**Why a round trip rather than a token you check yourself.** Your site holds an
API key, not a Capa signing secret, and it should stay that way: a signing secret
on a web server is a secret that can mint preview links for every project it can
reach. Verifying through Capa costs one request per preview click, which is a
click a human just made.

**The path is resolved fresh, never baked into the token.** Fix a route or
correct a slug and the next preview link lands in the right place, rather than an
hour later when the old token expires. `path` and `namespace` are `null` when the
model has since lost its route or the entry's slug was emptied; the claim is
still valid and your own routing is the fallback.

| Status | `code`                  | When                                                           |
| ------ | ----------------------- | -------------------------------------------------------------- |
| 401    | `preview_token_invalid` | not a Capa token, tampered with, or minted for another project |
| 401    | `preview_token_expired` | older than an hour                                             |

A token minted for another project answers the same `preview_token_invalid` a
forged one gets, so the refusal is not a way to learn that a token is real but
not yours.

Preview responses are always `Cache-Control: no-store`. A claim names one draft
of one entry for one hour, and a shared cache holding it would serve it to the
next visitor after the editor closed the tab.

## What is not counted

`reads30d` counts times a page asked **Capa** for data. It is not a visitor
count and must not be read as one.

A page served from a CDN cache never reaches Capa, so a popular page behind a
warm cache can report far fewer reads than it has visitors. A page rebuilt at
deploy time reports one read per build. A page rendered per request reports one
read per request.

What the number is good for is relative: which pages read which entries, which
pages are fetching more than they need, and which declared routes nothing is
reading at all.

## Retention

| Table          | Kept for                   | What it holds                                                                    |
| -------------- | -------------------------- | -------------------------------------------------------------------------------- |
| raw reads      | 7 days                     | one row per read, with the entry ids it returned and the schema stamp it carried |
| daily rollup   | 90 days                    | one row per page, model, key, select and day, with the last schema stamp seen    |
| unused entries | until the next nightly run | one row per entry nothing renders, replaced per project each night               |

Raw rows are folded into the daily rollup once a day is complete, and a raw day
is never deleted before it has been folded. This is why `reads30d` reaches back
thirty days while anything involving entry ids reaches back seven: only the raw
rows carry the ids, and the daily rows keep a capped sample rather than a full
list.

Today is folded too, so the numbers move during the day rather than waiting for
midnight, but the rollup does not mark today as covered until it is complete. A
read is counted from exactly one of the two tables: the rollup's own days come
from the daily rows, and today comes from the raw rows. `reads30d`, `readsByDay`
and `queries[].reads` all split at that same point, so the parts always sum to
the whole.

`entries[]`, `entryReads` and the `fanout` suggestion do not use that split at
all. They can only be answered from the raw rows, so their window is the seven
days those are kept, whatever the rollup has already folded.

## Turning it on

1. **Declare your routes.** In the Capa admin, give each model that publishes
   pages a route and, for `[name]` routes, a slug field. This alone fills the
   `declared` half of the list and makes Preview work.
2. **Send `Capa-Page`.** Add the header to your entry reads, naming the page you
   are rendering. With `@capacms/sdk/next` that is the `page` option. This fills the
   `observed` half and is what makes "which pages read this entry" answerable.
3. **Send `Capa-Schema`.** Pass `CAPA_SCHEMA_CHECKSUM` from your generated types
   to `createClient`. This is what makes the `drift` suggestion possible.
4. **Set your preview URL.** Project settings, so preview links have somewhere to
   point.

Each step is useful on its own, and none of them changes a byte of what your site
is already served.

## From the SDK

Capa can tell you which of **your** pages read which entries, and it can open a
draft in your own site. Both are opt in and both live on `@capacms/sdk/next` and
`@capacms/sdk/nextjs`.

### Tell Capa which page a read is for

Set `page` and every read sends a `Capa-Page` header. Capa records it and
answers exactly as it would have without it: same body, same `ETag`, same cache
key. Nothing about your site changes except that Capa can now answer "what
breaks if I unpublish this?".

```ts
import { createClient } from "@capacms/sdk/next";
import { routeOf } from "@capacms/sdk/nextjs";

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

// In app/blog/[slug]/page.tsx
const posts = await capa.entries.list("articles", { page: routeOf(import.meta.url) });
```

Send `path` beside it, the concrete path being rendered, and Capa can list the
real URLs an entry appears on, not only the route patterns:

```ts
await capa.entries.list("articles", { page: "/blog/[slug]", path: `/blog/${slug}` });
```

`path` goes out as `Capa-Path`, only when `page` is also set.

`routeOf` turns a Next route file into the page string: `/blog/[slug]`. It
drops route groups `(marketing)`, parallel slots `@modal`, the leaf file name
and the extension. Write the string out by hand if you prefer; `routeOf` exists
so that moving a folder cannot silently split one page's telemetry in two. Set
`page` on the config instead when a client serves exactly one page; a value on
the call wins over one on the config.

A layout is not a page. `app/layout.tsx` (and any nested `layout.*` or
`template.*`) renders around every page below it, and Next does not tell it
which one, so its reads cannot be charged to the page being rendered. `routeOf`
returns `"(layout)"` for these files, exported as `LAYOUT_PAGE`, and a read that
names it sends no `Capa-Page` header at all, even when the client was created
with a `page`. So a Site singleton or a nav read in your root layout is simply
not attributed, instead of making `/` look as if it read everything:

```ts
// In app/layout.tsx: same call as in a page, and no page is recorded.
const site = await capa.entries.list("site", { page: routeOf(import.meta.url) });
```

A malformed value throws a `TypeError`. Capa itself ignores a header it cannot
store, because a mangled page identity must never take a blog down, so the SDK
is the place a typo surfaces.

### Read the page list

```ts
const { data: pages } = await capa.pages.list();
// [{ id: "/blog/[slug]", kind: "both", models: [...], reads30d: 812, ... }]

const appearsOn = await capa.pages.list({ entry: "entry-id" });
// only the pages that read that entry, each with entryReads
```

A page is `declared` when a model carries a route for it, `observed` when a read
arrived carrying it as `Capa-Page`, and `both` when a correctly wired site has
done both. `capa.pages.get("/blog/[slug]")` adds the entries the page reads, the
queries it makes and a per-day read count, and returns `null` for a page Capa
has never heard of.

### Tell Capa which schema you built against

Pass `CAPA_SCHEMA_CHECKSUM` from your generated types and every read sends a
`Capa-Schema` header:

```ts
import { CAPA_SCHEMA_CHECKSUM } from "./capa-types";

const capa = createClient({
  baseUrl,
  apiKey,
  version: "2026-10-01",
  schemaChecksum: CAPA_SCHEMA_CHECKSUM,
});
```

`capa-codegen` writes that constant into the generated file on every run, so it
is always the schema the committed types describe. It buys one thing nothing
else can work out: whether your deployed site was built against the models the
project has now. A site that has never been rebuilt keeps sending perfectly
valid requests, so without the stamp a stale build is invisible, and with it the
page detail says "the site was generated from an older schema" and tells you to
re-run `capa-codegen`.

Telemetry only, like `page`: it does not change a response, a cache key or an
`ETag`, and Capa ignores a value it cannot parse. A bad value throws a
`TypeError` where the client is built, because a stamp dropped in silence looks
exactly like a site that is up to date.

### Read the suggestions

`capa.pages.get(page)` carries an `insights` array: suggestions drawn from that
page's own reads, computed by Capa so that this SDK, the Capa admin and
Capa's MCP server all read one answer.

```ts
const detail = await capa.pages.get("/blog/[slug]");
for (const insight of detail?.data.insights ?? []) {
  console.log(insight.severity, insight.title, insight.evidence);
  if (insight.rewrite?.select) console.log("try select=" + insight.rewrite.select);
}
```

| kind        | what it found                                                    |
| ----------- | ---------------------------------------------------------------- |
| `overfetch` | the page sends no `select` on a wide model or one with relations |
| `fanout`    | the page reads one model an entry at a time instead of filtering |
| `cache`     | most of the page's reads miss or bypass the edge                 |
| `drift`     | the `Capa-Schema` the page sent is not the project's current one |
| `unused`    | entries no page reads, on `pages.list()` under `meta.insights`   |

Each one carries `title` (one sentence), `detail` (why it matters), `evidence`
(every number the rule used, so you can check the claim) and, where there is
one, `rewrite` with a canonical `select`, a set of query parameters or a whole
URL you can paste.

Every `queries[]` row on the detail also carries `selection`, the parsed
Selection IR for its `select`, or `selectionError` when the stored `select` no
longer parses against the model's current schema. That second case is usually
not a bug: it is a query naming a field the project has since removed.

`unused` insights live on the LIST rather than on a page, because "no page reads
this" is a claim about every page at once. They are in `meta.insights` on
`capa.pages.list()`.

### Open a draft in your own site

An editor presses Preview in Capa and gets a link to **your** site carrying a
signed token. Your site asks Capa whether the token is good, enables draft mode
and redirects to the page. The token is short lived and names one entry.

```ts
// app/api/preview/route.ts
import { draftMode } from "next/headers";
import { redirect } from "next/navigation";
import { createClient } from "@capacms/sdk/next";
import { preview } from "@capacms/sdk/nextjs";

export async function GET(request: Request) {
  const token = new URL(request.url).searchParams.get("capa-preview") ?? "";
  const claim = await preview(token, createClient({ baseUrl, apiKey, version }));
  if (!claim) return new Response("Invalid or expired preview link", { status: 401 });
  (await draftMode()).enable();
  redirect(claim.path ?? "/");
}
```

`preview` returns `null` for an invalid or an expired token, because a preview
route does the same thing for both: do not enable draft mode. Anything else
throws, so a Capa outage is never mistaken for a stale link. `claim.path` is
resolved fresh on every call rather than baked into the token, so fixing a route
or a slug takes effect immediately; it is `null` when the model has no route, and
your own routing is the fallback.

Draft mode still needs a draft key. `draftClient` selects one:

```ts
const capa = await draftClient({
  production: { baseUrl, apiKey: PUBLISHED_KEY, version },
  draft: { baseUrl, apiKey: PREVIEW_KEY, version },
  isDraft: async () => (await draftMode()).isEnabled,
});
```

Set the preview base URL for your project in Capa (Settings), otherwise the
editor sees the path without a link to open it.
