Docs

Pages

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

View as Markdown

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 for how a /api/ request is shaped and Keys and scopes for how to mint a key. These routes need the instance:read scope, the same one the entry routes need.

RouteWhat it answers
GET /api/pagesevery 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
GET /api/previewis 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). 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

curl https://cdn.capacms.com/api/pages \
  -H "x-api-key: $CAPA_KEY" \
  -H 'Capa-Version: 2026-10-01'
{
  "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": []
  }
}
FieldRead it as
id, patternthe page string. They are equal: the string is the identity
kinddeclared, observed, or both
modelsthe 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
reads30dorigin reads in the window meta.since opens. See what is not counted
lastReadAtthe most recent read, or null for a declared page nobody has loaded
slugFieldthe 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

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). 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:

curl "https://cdn.capacms.com/api/pages/$(printf %s '/blog/[slug]' | jq -sRr @uri)" \
  -H "x-api-key: $CAPA_KEY"
{
  "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.

FieldRead it as
urlthe 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
selectionthe 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.)

{
  "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": "…:"
}
FieldRead it as
kindwhich rule found it: overfetch, fanout, cache, drift, unused
severitywarn is worth acting on, info is worth knowing
titleone sentence, present tense. The numbers are in evidence, not hidden in here
detailwhy it matters, in one or two sentences
rewritethe copyable fix, when the rule can build one. Absent when it cannot, because a suggestion nobody can act on is noise wearing a button
evidenceevery number the rule used, so you can check the claim rather than trust it
modelIdthe model it is about, when it is about one
queryKeythe 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 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:

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:

curl 'https://cdn.capacms.com/api/preview?token=<token>' \
  -H "x-api-key: $CAPA_KEY"
{
  "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.

StatuscodeWhen
401preview_token_invalidnot a Capa token, tampered with, or minted for another project
401preview_token_expiredolder 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

TableKept forWhat it holds
raw reads7 daysone row per read, with the entry ids it returned and the schema stamp it carried
daily rollup90 daysone row per page, model, key, select and day, with the last schema stamp seen
unused entriesuntil the next nightly runone 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?".

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:

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:

// 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

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:

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.

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);
}
kindwhat it found
overfetchthe page sends no select on a wide model or one with relations
fanoutthe page reads one model an entry at a time instead of filtering
cachemost of the page's reads miss or bypass the edge
driftthe Capa-Schema the page sent is not the project's current one
unusedentries 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.

// 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:

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.