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 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.
| 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 |
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). 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": []
}
}| 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 |
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
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.
| 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.)
{
"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 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: 7199153b8f2bd4cfThe 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:
- 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.
- It never varies the response. Not in
Vary, not echoed, and the body, theETagand theSurrogate-Keyare byte for byte what they would have been without it. - 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.
| 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
- 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 thedeclaredhalf of the list and makes Preview work. - Send
Capa-Page. Add the header to your entry reads, naming the page you are rendering. With@capacms/sdk/nextthat is thepageoption. This fills theobservedhalf and is what makes "which pages read this entry" answerable. - Send
Capa-Schema. PassCAPA_SCHEMA_CHECKSUMfrom your generated types tocreateClient. This is what makes thedriftsuggestion possible. - 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 entryReadsA 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);
}| 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.
// 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.