The API is HTTPS with a bearer header, so each framework’s own data loading is
the client. There is no official client library — use fetch, or
generate types.
Two rules hold everywhere:
- The token stays on the server. It reads everything the workspace publishes and may write.
- Reader tags come from your server. If you use
segments, your backend decides who the visitor is
and sends
X-Reader-Tags. Never copy a value from the browser.
Every example below reads these variables:
ORTHACMS_URL=https://cms.example.com
# A read-scope token
ORTHACMS_TOKEN=orthacms_…
# Only needed for a token that covers several workspaces
ORTHACMS_WORKSPACE_ID=And maps your own user to segment tags with a function like this — the rule is yours:
// readerTags.ts
export interface SiteUser {
plan: 'free' | 'premium';
region?: string;
}
export function readerTagsFor(user: SiteUser | null | undefined): string[] {
if (!user) return []; // anonymous: unrestricted content only
return [user.plan, user.region].filter((tag): tag is string => !!tag);
}Plain Node
A small server-side client for REST and GraphQL. The framework sections below inline the same headers.
// orthacms.ts — import only from server code
const BASE = process.env.ORTHACMS_URL ?? 'http://localhost:3000';
const TOKEN = process.env.ORTHACMS_TOKEN ?? '';
const WORKSPACE = process.env.ORTHACMS_WORKSPACE_ID;
export interface CallOptions {
readerTags?: readonly string[];
token?: string; // e.g. a full token for preview
}
function headersFor({ readerTags = [], token = TOKEN }: CallOptions = {}) {
const headers: Record<string, string> = { Authorization: `Bearer ${token}` };
if (WORKSPACE) headers['X-Workspace-Id'] = WORKSPACE;
if (readerTags.length) headers['X-Reader-Tags'] = readerTags.join(',');
return headers;
}
/** GET /api/v1{path}. Resolves to null on a 404. */
export async function rest<T>(path: string, options?: CallOptions): Promise<T | null> {
const response = await fetch(`${BASE}/api/v1${path}`, { headers: headersFor(options) });
if (response.status === 404) return null;
if (!response.ok) throw new Error(`Ortha answered ${response.status} for ${path}`);
return (await response.json()) as T;
}
/** POST /api/v1/graphql. Throws on any GraphQL error. */
export async function graphql<T>(
query: string,
variables: Record<string, unknown> = {},
options?: CallOptions
): Promise<T> {
const response = await fetch(`${BASE}/api/v1/graphql`, {
method: 'POST',
headers: { ...headersFor(options), 'Content-Type': 'application/json' },
body: JSON.stringify({ query, variables })
});
if (!response.ok) throw new Error(`Ortha answered ${response.status}`);
const { data, errors } = await response.json();
if (errors?.length) {
throw new Error(errors.map((e: { message: string }) => e.message).join('; '));
}
return data as T;
}// read.ts
import { graphql, rest } from './orthacms';
const page = await rest<{ items: { values: { slug: string; title: string } }[] }>(
'/content/article?fields=title,slug&pageSize=10',
{ readerTags: ['premium'] }
);
for (const entry of page?.items ?? []) console.log(entry.values.slug, entry.values.title);
const data = await graphql<{ articles: { total: number } }>(
'query($locale: String) { articles(locale: $locale) { total } }',
{ locale: 'de' },
{ readerTags: ['premium'] }
);
console.log(data.articles.total);Astro
At build time
The content layer fetches once per build. A build has no visitor, so it is the anonymous reader: restricted entries are not included.
// src/content.config.ts
import { defineCollection, z } from 'astro:content';
const { ORTHACMS_URL, ORTHACMS_TOKEN } = import.meta.env;
interface Entry {
id: string;
publishedAt?: string | null;
localeGroupId?: string;
values: { title: string; slug: string; excerpt: string | null };
}
async function readAll(): Promise<Entry[]> {
const items: Entry[] = [];
for (let page = 1; ; page += 1) {
const response = await fetch(
`${ORTHACMS_URL}/api/v1/content/article?fields=title,slug,excerpt&sort=-publishedAt&pageSize=100&page=${page}`,
{ headers: { Authorization: `Bearer ${ORTHACMS_TOKEN}` } }
);
if (!response.ok) throw new Error(`Ortha answered ${response.status}`);
const body = await response.json();
items.push(...body.items);
if (body.page * body.pageSize >= body.total) return items;
}
}
const articles = defineCollection({
loader: async () =>
(await readAll()).map((entry) => ({
id: entry.id,
group: entry.localeGroupId,
publishedAt: entry.publishedAt ?? null,
...entry.values
})),
schema: z.object({
group: z.string().optional(),
publishedAt: z.string().nullable(),
title: z.string(),
slug: z.string(),
excerpt: z.string().nullable()
})
});
export const collections = { articles };---
// src/pages/index.astro
import { getCollection } from 'astro:content';
const articles = await getCollection('articles');
---
<ul>
{articles.map((a) => <li><a href={'/' + a.data.slug}>{a.data.title}</a></li>)}
</ul>pageSizeis capped at 100, so the loop is required.- Variables without the
PUBLIC_prefix never reach the client bundle.
Per request, with reader tags (SSR)
For restricted content, render on demand and send the visitor’s tags. Assumes
your auth middleware puts the user on Astro.locals.user.
---
// src/pages/[locale]/[group].astro
export const prerender = false;
import { readerTagsFor } from '../../readerTags';
const { locale, group } = Astro.params;
const tags = readerTagsFor(Astro.locals.user);
const response = await fetch(
`${import.meta.env.ORTHACMS_URL}/api/v1/content/article/group/${group}?locale=${locale}`,
{
headers: {
Authorization: `Bearer ${import.meta.env.ORTHACMS_TOKEN}`,
...(tags.length ? { 'X-Reader-Tags': tags.join(',') } : {})
}
}
);
if (response.status === 404) return Astro.redirect('/404');
const article = await response.json();
---
<h1>{article.values.title}</h1>Next.js (App Router)
Server Components run on the server, so they can call Ortha directly. Use
cache: 'no-store' once reader tags are involved — the answer differs per
visitor.
// app/[locale]/[group]/page.tsx
import { notFound } from 'next/navigation';
import { getSession } from '@/lib/auth'; // your auth
import { readerTagsFor } from '@/lib/readerTags';
export default async function Page({
params
}: {
params: Promise<{ locale: string; group: string }>;
}) {
const { locale, group } = await params;
const tags = readerTagsFor((await getSession())?.user);
const response = await fetch(
`${process.env.ORTHACMS_URL}/api/v1/content/article/group/${group}?locale=${locale}&relations=preview&relationFields=author`,
{
headers: {
Authorization: `Bearer ${process.env.ORTHACMS_TOKEN}`,
...(tags.length ? { 'X-Reader-Tags': tags.join(',') } : {})
},
...(tags.length ? { cache: 'no-store' as const } : { next: { revalidate: 60 } })
}
);
if (response.status === 404) notFound();
if (!response.ok) throw new Error(`Ortha answered ${response.status}`);
const article = await response.json();
return (
<article>
<h1>{article.values.title}</h1>
<p>by {article.relations.author.items[0]?.values.name}</p>
</article>
);
}Variables without NEXT_PUBLIC_ stay on the server. For draft preview, see
preview and drafts.
Nuxt
useFetch runs in the browser after navigation, so the token lives in
private runtime config and only a server route calls Ortha.
// nuxt.config.ts
export default defineNuxtConfig({
runtimeConfig: {
// Private. Set with NUXT_ORTHACMS_URL / NUXT_ORTHACMS_TOKEN.
orthacmsUrl: 'http://localhost:3000',
orthacmsToken: ''
}
});// server/api/articles.get.ts
import { readerTagsFor } from '~/utils/readerTags';
export default defineEventHandler(async (event) => {
const { orthacmsUrl, orthacmsToken } = useRuntimeConfig(event);
// Set by your own auth middleware — never read from the request body or query.
const tags = readerTagsFor(event.context.user);
return $fetch(orthacmsUrl + '/api/v1/content/article', {
headers: {
Authorization: 'Bearer ' + orthacmsToken,
...(tags.length ? { 'X-Reader-Tags': tags.join(',') } : {})
},
query: {
locale: getQuery(event).locale ?? 'en',
fields: 'title,slug,excerpt',
sort: '-publishedAt',
pageSize: 20
}
});
});<!-- pages/index.vue -->
<script setup lang="ts">
const { data } = await useFetch('/api/articles', { query: { locale: 'en' } });
</script>
<template>
<ul>
<li v-for="entry in data?.items" :key="entry.id">
<NuxtLink :to="'/' + entry.localeGroupId">{{ entry.values.title }}</NuxtLink>
</li>
</ul>
</template>The browser calls /api/articles on your origin. Only Nitro calls Ortha. An
image proxy for /api/v1/media/assets/:id/raw belongs in server/api/ too.
SvelteKit
A +page.server.ts load runs only on the server, and $env/static/private
cannot be imported into client code.
// src/routes/[locale]/[group]/+page.server.ts
import { error } from '@sveltejs/kit';
import { ORTHACMS_TOKEN, ORTHACMS_URL } from '$env/static/private';
import { readerTagsFor } from '$lib/readerTags';
import type { PageServerLoad } from './$types';
export const load: PageServerLoad = async ({ params, fetch, locals }) => {
// locals.user is set in your hooks.server.ts
const tags = readerTagsFor(locals.user);
const response = await fetch(
`${ORTHACMS_URL}/api/v1/content/article/group/${encodeURIComponent(params.group)}` +
`?locale=${encodeURIComponent(params.locale)}&translations=preview`,
{
headers: {
Authorization: `Bearer ${ORTHACMS_TOKEN}`,
...(tags.length ? { 'X-Reader-Tags': tags.join(',') } : {})
}
}
);
if (response.status === 404) error(404, 'No such article');
if (!response.ok) error(502, `Ortha answered ${response.status}`);
return { article: await response.json() };
};<!-- src/routes/[locale]/[group]/+page.svelte -->
<script lang="ts">
let { data } = $props();
</script>
<h1>{data.article.values.title}</h1>
<nav>
{#each data.article.translations as t}
<a href={`/${t.locale}/${t.localeGroupId}`}>{t.locale}</a>
{/each}
</nav>Use the fetch from the load argument: SvelteKit inlines the response into the
page, so hydration does not repeat the call.
GraphQL from any framework
Same headers, one POST. With the graphql() helper from Plain
Node:
const QUERY = /* GraphQL */ `
query Article($group: ID!, $locale: String) {
article(localeGroupId: $group, locale: $locale) {
title
author { name }
coverImage { url alt }
}
}
`;
const { article } = await graphql<{ article: { title: string } | null }>(
QUERY,
{ group, locale },
{ readerTags: readerTagsFor(user) }
);
// A missing entry is null, not an error — render your 404.Caching with reader tags
- Build-time output and shared CDN caches see only the anonymous reader.
- A response fetched with
X-Reader-Tagsbelongs to that set of tags. Cache it per tag set, or not at all. - Webhooks tell you when to purge: reacting to changes.
Generating a typed client
OpenAPI. Served at /reference/json (on outside production, or with
API_DOCS=true):
curl -s http://localhost:3000/reference/json > orthacms-openapi.json
npx openapi-typescript orthacms-openapi.json -o orthacms.d.tsIt also describes the admin API; keep the /api/v1/ paths. /api/v1/mcp is
not in it.
GraphQL. GET /api/v1/graphql returns the SDL for your token’s
workspace:
curl -s http://localhost:3000/api/v1/graphql \
-H 'Authorization: Bearer orthacms_…' \
-H 'X-Workspace-Id: 3f7a0c2e-5b1d-4e8a-9f3c-7d2e1a6b4c90' > schema.graphql// codegen.ts
import type { CodegenConfig } from '@graphql-codegen/cli';
const config: CodegenConfig = {
schema: 'schema.graphql',
documents: 'src/**/*.{ts,tsx,graphql}',
generates: { 'src/gql/': { preset: 'client' } }
};
export default config;npx graphql-codegenGenerate per workspace
Tokens over different workspace grants get different SDL. A client generated for workspace A does not describe workspace B — regenerate per consumer.
What to read next
- Preview and drafts — a preview route that keeps the
fulltoken off the browser. - The media API — proxying asset bytes.