An editor wants to see the page before publishing it. The public API defaults to published-only, and that default is guarded rather than merely assumed — so preview is a deliberate widening, taken by a token that could have published the row anyway, and kept off the browser entirely.
The rule
?status=draft and ?status=any are refused for a read token:
HTTP/1.1 403 Forbidden
{ "statusCode": 403, "message": "status=draft requires a token with write scope." }The probe is content:update, not content:read — read is what both scopes
hold, and update is what separates them. A full token passes. The check is a
guard on the whole controller, so it covers every read that could reach a
draft — the entry itself, its sibling translations, a relation expansion, an
entry’s media — rather than each route remembering on its own. GraphQL takes
the same argument as status: DRAFT or status: ANY and enforces the same
rule.
status | Returns | Needs |
|---|---|---|
published (default) | Live rows only | read |
draft | Rows whose current state is draft — including live entries with unpublished edits | full |
any | Every row regardless of state | full |
On a type that is not publishable, status is ignored: those rows have no
publish state and are all live.
A draft read carries the same envelope as a published one, and the envelope
tells you which it is: status is draft, and publishedAt is null for a
never-published entry or set for a live entry carrying unpublished edits — the
modified state.
curl 'https://cms.example.com/api/v1/content/article/group/b2d1…?locale=de&status=any' \
-H 'Authorization: Bearer apograph_…' # a full-scope tokenA preview route that never ships the token
The full token reads drafts and can also create, publish and delete every
granted type in every workspace of its bucket. It belongs in the server’s
environment and nowhere else. A preview flow therefore has three parts, none of
which hands the browser anything but a cookie.
- A preview link the editor opens, carrying a random secret the site
knows:
https://site.example.com/api/preview?secret=…&locale=de&group=b2d1…. - A route that checks the secret, turns on the framework’s draft mode, and redirects to the page. Draft mode is a cookie; the token is not in it.
- Pages that read the draft-mode flag and add
status=anyto the request they were already making — with thefulltoken, server-side.
In Next.js App Router that is one route handler and one line in the page:
// app/api/preview/route.ts
import { draftMode } from 'next/headers';
import { redirect } from 'next/navigation';
export async function GET(request: Request) {
const url = new URL(request.url);
if (url.searchParams.get('secret') !== process.env.PREVIEW_SECRET) {
return new Response('Invalid secret', { status: 401 });
}
const locale = url.searchParams.get('locale') ?? 'en';
const group = url.searchParams.get('group');
if (!group) {
return new Response('Missing group', { status: 400 });
}
(await draftMode()).enable();
redirect('/' + locale + '/' + group);
}// in the page's data call
import { draftMode } from 'next/headers';
const { isEnabled } = await draftMode();
const status = isEnabled ? '&status=any' : '';
const entry = await apograph('/content/article/group/' + group + '?locale=' + locale + status);Use two tokens: the read token for the ordinary build, and the full
token only when isEnabled is true. A site that holds a full token for every
request is a site whose every request could publish.
The group-addressed route is what makes the preview link stable: the editor’s
draft has a localeGroupId from the moment it is saved, before it has ever
been published, so the same URL works for the draft and for the live page.
Sibling translations widen too
?translations=preview under status=any includes unpublished translations,
which is what an editor previewing a language switcher wants and not what a
public page wants. The guard is per request, so the two shapes cannot leak into
each other — but a preview render and a public render must not share a cache
entry. Draft mode already bypasses the route cache in Next.js; check the
equivalent in your framework.
The browser cannot call the API directly
The server sends no CORS headers. Nothing in Apograph enables CORS, on any
route, and a cross-origin fetch with an Authorization header is preflighted
and refused by the browser before it is sent. That is not an oversight to work
around: a browser that could call the API would need the token, and the token
is not a browser-grade credential.
ALLOWED_ORIGINS is often mistaken for a CORS allow-list. It is not one. It
feeds an origin guard on the admin’s state-changing session routes — sign
in, sign out, invite and reset redemption, preferences, session revocation,
and minting API tokens — which rejects a browser-set Origin outside the list
as a login-CSRF defence. It never touches /api/v1, and a non-browser client
sends no Origin and passes it anyway.
So a front end that needs data after hydration talks to its own server, which holds the token:
| Framework | Where the proxy lives |
|---|---|
| Next.js | A route handler under app/api/… |
| Nuxt | A server route under server/api/… |
| SvelteKit | A +server.ts endpoint |
| Astro | A server endpoint under src/pages/api/…, in SSR mode |
Front-end frameworks has the code for each.
What the API caches, and what it does not
Content reads send no Cache-Control header of their own. They are plain
GET requests, and what a CDN or your own fetch layer does with them is your
decision; Apograph neither forbids caching nor sends an invalidation.
What you do get is Express’s default validator: every JSON response carries a
weak ETag computed from the body, and a request with a matching
If-None-Match is answered 304 Not Modified. Apograph neither sets nor
disables it. Two things follow. It saves the transfer, not the work — the
server still runs the query and serialises the body to compute the tag. And it
is a content hash, not a version: a response that happens to be
byte-identical after an edit and a revert validates as fresh, which is correct.
Media is different:
- Streamed bytes from
/api/v1/media/assets/:id/rawcarryX-Content-Type-Options: nosniff, a restrictiveContent-Security-Policyand aContent-Disposition, and noCache-Control. - When
MEDIA_DIRECT_SERVE=signed-urlis on, the route answers a 302 withCache-Control: private, no-store, because a cached redirect would outlive the signed URL it points at. Follow the redirect and cache what comes back on your own terms.
For “re-render the page when it is published” rather than “re-check on a timer”, subscribe a webhook — see reacting to changes.
What to read next
- API tokens — the two scopes, and why the draft widening is gated on the one that could have published.
- Reading entries — the group-addressed routes a preview link is built on.