Features Apograph CMS on GitHub

Preview and drafts

Content plugin @apograph/content-server@apograph/content-admin

The rule behind ?status=draft, a preview route that never ships the token, and what the API actually caches.

Documents 0.5.2 Updated Edit this page Report a problem

On this page

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.

statusReturnsNeeds
published (default)Live rows onlyread
draftRows whose current state is draft — including live entries with unpublished editsfull
anyEvery row regardless of statefull

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 token

A 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.

  1. A preview link the editor opens, carrying a random secret the site knows: https://site.example.com/api/preview?secret=…&locale=de&group=b2d1….
  2. 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.
  3. Pages that read the draft-mode flag and add status=any to the request they were already making — with the full token, 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:

FrameworkWhere the proxy lives
Next.jsA route handler under app/api/…
NuxtA server route under server/api/…
SvelteKitA +server.ts endpoint
AstroA 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/raw carry X-Content-Type-Options: nosniff, a restrictive Content-Security-Policy and a Content-Disposition, and no Cache-Control.
  • When MEDIA_DIRECT_SERVE=signed-url is on, the route answers a 302 with Cache-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.

  • 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.