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.tsThe 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.
What to read next
- Preview and drafts — how a preview route keeps the
fulltoken off the browser. - The media API — why asset URLs need the token, and the two shapes that work around it.