The public API serves published content. Preview is a deliberate widening: a
full token asks for drafts, from your server, never from the browser.
The rule
status | Returns | Token |
|---|---|---|
published (default) | Published entries. A modified entry returns its last published version (0.7.0). | read or full |
draft | Rows whose working copy is a draft — new drafts and modified entries, with their unpublished edits | full |
any | Every row’s working copy | full |
curl 'https://cms.example.com/api/v1/content/article/group/b2d1…?locale=de&status=any' \
-H 'Authorization: Bearer orthacms_…' # full-scope token const response = await fetch(
'https://cms.example.com/api/v1/content/article/group/' + group +
'?locale=de&status=any',
{ headers: { Authorization: 'Bearer ' + process.env.ORTHACMS_PREVIEW_TOKEN } }
); query Preview($group: ID!) {
article(localeGroupId: $group, locale: "de", status: ANY) {
id
status
publishedAt
title
}
} A read token asking for drafts:
{ "message": "status=any requires a token with write scope.", "error": "Forbidden", "statusCode": 403 }- The check is on the whole controller: the entry, its
translations, relation pages and media all follow it. GraphQL (status: DRAFT | ANY) and MCP enforce the same rule. - Non-publishable types ignore
status— every row is live. - Expanded relation targets are published only, whatever
statussays.
Tell the states apart from the envelope:
status | publishedAt | State |
|---|---|---|
draft | null | Never published |
draft | set | Modified — live, with unpublished edits |
published | set | Published, no pending edits |
A preview route that never ships the token
- Preview link with a secret the site knows:
https://site.example.com/api/preview?secret=…&locale=de&group=b2d1… - Route checks the secret, turns on the framework’s draft mode (a cookie), redirects to the page.
- Page sees draft mode and adds
status=any, using thefulltoken, server-side.
Next.js App Router:
// 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);
}// lib/orthacms.ts — server only
import { draftMode } from 'next/headers';
export async function readArticle(group: string, locale: string) {
const { isEnabled } = await draftMode();
// Two tokens: read for the public site, full only while previewing.
const token = isEnabled
? process.env.ORTHACMS_PREVIEW_TOKEN
: process.env.ORTHACMS_TOKEN;
const status = isEnabled ? '&status=any' : '';
const response = await fetch(
process.env.ORTHACMS_URL + '/api/v1/content/article/group/' +
encodeURIComponent(group) + '?locale=' + encodeURIComponent(locale) + status,
{
headers: { Authorization: 'Bearer ' + token },
cache: isEnabled ? 'no-store' : 'force-cache'
}
);
if (response.status === 404) return null;
if (!response.ok) throw new Error('Ortha answered ' + response.status);
return response.json();
}- Two tokens. A site that holds a
fulltoken on every request is a site whose every request could publish. - The group id exists from the first save, so the same URL works for the draft and the live page.
Sibling translations widen too
?translations=preview under status=any includes unpublished translations.
Never let a preview render and a public render share a cache entry.
The browser cannot call the API directly
- Ortha sends no CORS headers on any route. A cross-origin
fetchwith anAuthorizationheader is refused by the browser. ALLOWED_ORIGINSis not a CORS list. It guards the admin API’s cookie-authenticated writes against CSRF and never touches/api/v1.
A front end that needs data after hydration calls its own server:
| 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 | An endpoint under src/pages/api/…, in SSR mode |
Code for each: front-end frameworks.
What the API caches
- Content reads send no
Cache-Control. Caching is your decision. - JSON responses carry Express’s weak
ETag.If-None-Matchwith a match answers 304. The query still runs — it saves transfer, not work. - Responses read with
X-Reader-Tagsorstatus=differ per caller. Do not share one cache entry across them.
Media:
/api/v1/media/assets/:id/rawstreams withX-Content-Type-Options: nosniff, a restrictiveContent-Security-Policy, aContent-Disposition, and noCache-Control.- With
MEDIA_DIRECT_SERVE=signed-urlit answers 302 withCache-Control: private, no-store— a cached redirect would outlive the signed URL.
To re-render when something is published, subscribe a webhook: reacting to changes.
What to read next
- API tokens — the two scopes.
- Reading entries — the group-addressed routes a preview link is built on.