Beta . Ortha CMS is in public beta. Every plan is free until it ends.
Features Ortha CMS on GitHub

Preview and drafts

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

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

Documents 0.13.1 Updated Edit this page Report a problem

On this page

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

statusReturnsToken
published (default)Published entries. A modified entry returns its last published version (0.7.0).read or full
draftRows whose working copy is a draft — new drafts and modified entries, with their unpublished editsfull
anyEvery row’s working copyfull
Example language
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 status says.

Tell the states apart from the envelope:

statuspublishedAtState
draftnullNever published
draftsetModified — live, with unpublished edits
publishedsetPublished, no pending edits

A preview route that never ships the token

  1. Preview link with a secret the site knows: https://site.example.com/api/preview?secret=…&locale=de&group=b2d1…
  2. Route checks the secret, turns on the framework’s draft mode (a cookie), redirects to the page.
  3. Page sees draft mode and adds status=any, using the full token, 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 full token 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 fetch with an Authorization header is refused by the browser.
  • ALLOWED_ORIGINS is 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:

FrameworkWhere the proxy lives
Next.jsA route handler under app/api/…
NuxtA server route under server/api/…
SvelteKitA +server.ts endpoint
AstroAn 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-Match with a match answers 304. The query still runs — it saves transfer, not work.
  • Responses read with X-Reader-Tags or status= differ per caller. Do not share one cache entry across them.

Media:

  • /api/v1/media/assets/:id/raw streams with X-Content-Type-Options: nosniff, a restrictive Content-Security-Policy, a Content-Disposition, and no Cache-Control.
  • With MEDIA_DIRECT_SERVE=signed-url it answers 302 with Cache-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.