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

Front-end frameworks

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

Where the token lives in each framework, how to pass reader tags from your backend, and how to generate a typed client.

Documents 0.13.1 Updated Edit this page Report a problem

On this page

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>
  • pageSize is 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-Tags belongs 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.ts

It 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-codegen

Generate per workspace

Tokens over different workspace grants get different SDL. A client generated for workspace A does not describe workspace B — regenerate per consumer.