Features Apograph CMS on GitHub

Front-end frameworks

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

Where the token lives in each framework, and how to generate a typed client since there is no official one.

Documents 0.5.2 Updated Edit this page Report a problem

On this page

The public API is plain HTTPS with a bearer header, so every framework’s own data-loading primitive is the client. The one rule that does not change between them: the token stays on the server. It reads every published entry in its workspace bucket and may write, and a browser is not a place to keep it.

There is no official client library. What exists instead is a generated OpenAPI document and a per-token GraphQL schema, and either can feed a generator — see the end of this page.

Astro

At build time, the content layer is the natural home: fetch once, validate the shape, and every page reads the collection. An inline loader is a function that returns entries with an id.

// src/content.config.ts
import { defineCollection, z } from 'astro:content';

const APOGRAPH_URL = import.meta.env.APOGRAPH_URL;
const APOGRAPH_TOKEN = import.meta.env.APOGRAPH_TOKEN;

interface Entry {
    id: string;
    publishedAt?: string | null;
    localeGroupId?: string;
    values: { title: string; slug: string; excerpt: string | null };
}

interface Page {
    items: Entry[];
    total: number;
    page: number;
    pageSize: number;
}

async function readAll(): Promise<Entry[]> {
    const items: Entry[] = [];
    for (let page = 1; ; page += 1) {
        const response = await fetch(
            APOGRAPH_URL +
                '/api/v1/content/article?fields=title,slug,excerpt&sort=-publishedAt&pageSize=100&page=' +
                page,
            { headers: { Authorization: 'Bearer ' + APOGRAPH_TOKEN } }
        );
        if (!response.ok) {
            throw new Error('Apograph answered ' + response.status);
        }
        const body = (await response.json()) as Page;
        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((article) => <li><a href={'/' + article.data.slug}>{article.data.title}</a></li>)}
</ul>

pageSize is capped at 100, so the loop is not optional for a real library. Variables prefixed anything other than PUBLIC_ never reach the client bundle, which is what keeps APOGRAPH_TOKEN server-side in Astro.

A page that needs one record with expansions can call fetch directly in its frontmatter instead; the loader is for the shape every page shares.

Nuxt

useFetch runs on the server during SSR and in the browser after navigation, so the token cannot live in the composable. Put it in private runtime config and call the API from a server route, which is the only code that sees it.

// nuxt.config.ts
export default defineNuxtConfig({
    runtimeConfig: {
        // Private — overridden by NUXT_APOGRAPH_URL / NUXT_APOGRAPH_TOKEN.
        apographUrl: 'http://localhost:3000',
        apographToken: ''
    }
});
// server/api/articles.get.ts
export default defineEventHandler(async (event) => {
    const { apographUrl, apographToken } = useRuntimeConfig(event);
    const locale = getQuery(event).locale ?? 'en';

    return $fetch(apographUrl + '/api/v1/content/article', {
        headers: { Authorization: 'Bearer ' + apographToken },
        query: {
            locale,
            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 Nuxt origin; only the Nitro server calls Apograph. The same server route is where an image proxy for /api/v1/media/assets/:id/raw belongs.

SvelteKit

A +page.server.ts load runs only on the server, and $env/static/private refuses to be imported from client code, so the token has one legal home.

// src/routes/[locale]/[group]/+page.server.ts
import { error } from '@sveltejs/kit';
import { APOGRAPH_TOKEN, APOGRAPH_URL } from '$env/static/private';
import type { PageServerLoad } from './$types';

export const load: PageServerLoad = async ({ params, fetch }) => {
    const response = await fetch(
        APOGRAPH_URL +
            '/api/v1/content/article/group/' +
            encodeURIComponent(params.group) +
            '?locale=' +
            encodeURIComponent(params.locale) +
            '&translations=preview',
        { headers: { Authorization: 'Bearer ' + APOGRAPH_TOKEN } }
    );

    if (response.status === 404) error(404, 'No such article');
    if (!response.ok) error(502, 'Apograph 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>

Use SvelteKit’s fetch from the load argument rather than the global: it serialises the response into the page’s data on SSR, so hydration does not repeat the request — which it could not do anyway, holding no token.

Plain Node

Nothing but fetch, which Node 18 and later ships.

// read.ts
const base = process.env.APOGRAPH_URL ?? 'http://localhost:3000';
const token = process.env.APOGRAPH_TOKEN;
const workspace = process.env.APOGRAPH_WORKSPACE_ID;

const response = await fetch(
    base + '/api/v1/content/article?fields=title,slug&pageSize=10',
    {
        headers: {
            Authorization: 'Bearer ' + token,
            ...(workspace ? { 'X-Workspace-Id': workspace } : {})
        }
    }
);

if (!response.ok) {
    throw new Error('Apograph answered ' + response.status);
}

const page = await response.json();
for (const entry of page.items) {
    console.log(entry.values.slug, entry.values.title);
}

X-Workspace-Id is only required when the token covers more than one workspace; a single-workspace token needs no header, and the wrong one is a 403 rather than a silent fallback.

Generating a typed client

Two machine-readable descriptions exist, and both are generated from the running code rather than written by hand.

OpenAPI. A running server serves its document at /reference/json. It is on outside production, and API_DOCS=true publishes it from a deployed instance. Fetch it and feed it to any generator:

curl -s http://localhost:3000/reference/json > apograph-openapi.json
npx openapi-typescript apograph-openapi.json -o apograph.d.ts

The document describes every route including the admin’s session-cookie API, so filter to the /api/v1/ paths if the generator lets you.

GraphQL. GET /api/v1/graphql returns the SDL — with a bearer token, because the schema is built per workspace grant set. Pass the header to your codegen’s introspection step:

curl -s http://localhost:3000/api/v1/graphql \
  -H 'Authorization: Bearer apograph_…' > schema.graphql
npx graphql-codegen --schema schema.graphql --documents 'src/**/*.graphql'

The schema you generate from is the one your token sees

Two tokens over different workspace grants get different SDL, deliberately. A client generated with a token for workspace A does not describe workspace B’s types, and that is the point rather than a defect — regenerate per consumer.

  • Preview and drafts — how a preview route keeps the full token off the browser.
  • The media API — why asset URLs need the token, and the two shapes that work around it.