A read returns scalars. Relations and media come back only when you ask.
curl -G 'https://cms.example.com/api/v1/content/article/9c4b…' \
-H 'Authorization: Bearer orthacms_…' \
-d 'relations=preview' -d 'relationFields=author,tags' \
-d 'media=preview' -d 'mediaFields=coverImage' const query = new URLSearchParams({
relations: 'preview',
relationFields: 'author,tags',
media: 'preview',
mediaFields: 'coverImage'
});
const response = await fetch(
'https://cms.example.com/api/v1/content/article/9c4b…?' + query,
{ headers: { Authorization: 'Bearer ' + process.env.ORTHACMS_TOKEN } }
);
const entry = await response.json();
const author = entry.relations.author.items[0] ?? null;
const tags = entry.relations.tags.items;
const cover = entry.media.coverImage.items[0] ?? null; query Article($id: ID!) {
article(id: $id) {
id
title
author {
name
}
tags(pageSize: 20) {
total
items {
id
name
}
}
coverImage {
url
alt
}
}
} GraphQL has no expansion parameters: select the field and it is resolved, to any depth within the cost limits.
Relations
| Parameter | Effect |
|---|---|
relations=preview | Turns expansion on. |
relationFields | Fields to expand, comma-separated. Omit to expand every readable relation field. |
relationLimit | Links per field, 1–100. Default 20. |
- Works on the list route and on single reads.
- At most 10 fields per kind per request. A type with more must name them.
- Naming a non-relation field, or one pointing to an unreadable type, is a 400.
Each expanded field is one page of full entries plus the true total:
{
"id": "9c4b…",
"values": { "title": "Hello, Postgres" },
"relations": {
"author": {
"items": [
{ "id": "a71c…", "createdAt": "…", "updatedAt": "…", "values": { "name": "Ada" }, "status": "published", "publishedAt": "…" }
],
"total": 1
},
"tags": {
"items": [
{ "id": "1f9a…", "createdAt": "…", "updatedAt": "…", "values": { "name": "Postgres", "slug": "postgres" }, "status": "published", "publishedAt": "…" }
],
"total": 34
}
}
}- A many-to-one field is a list of at most one. Take
items[0]. totalis the count of visible links. Compare it toitems.lengthto decide whether to page.- Only published targets are shown or counted — even under
?status=any. Drafts, soft-deleted targets and targets restricted byX-Reader-Tagsare missing from bothitemsandtotal. - A target with unpublished edits shows its published version (0.7.0).
Paging past the cap
GET /api/v1/content/:type/:id/relations/:field — page, pageSize (default
20, max 100).
curl 'https://cms.example.com/api/v1/content/article/9c4b…/relations/tags?page=2&pageSize=50' \
-H 'Authorization: Bearer orthacms_…' async function allTags(articleId) {
const tags = [];
for (let page = 1; ; page += 1) {
const response = await fetch(
'https://cms.example.com/api/v1/content/article/' + articleId +
'/relations/tags?pageSize=100&page=' + page,
{ headers: { Authorization: 'Bearer ' + process.env.ORTHACMS_TOKEN } }
);
const { items, total } = await response.json();
tags.push(...items);
if (tags.length >= total || items.length === 0) return tags;
}
} query MoreTags($id: ID!) {
article(id: $id) {
tags(page: 2, pageSize: 50) {
total
items { id name }
}
}
} Answers { "items": [ … ], "total": 34 }. The group-addressed twin:
GET /api/v1/content/article/group/:groupId/relations/tags?locale=deMedia
| Parameter | Effect |
|---|---|
media=preview | Turns media expansion on. |
mediaFields | Fields to expand, comma-separated. Omit for every media field. |
mediaLimit | Assets per field, 1–100. Default 20. |
{
"media": {
"coverImage": {
"items": [
{
"id": "3f7a…",
"name": "hero.jpg",
"url": "/api/v1/media/assets/3f7a…/raw",
"thumbUrl": "/api/v1/media/assets/3f7a…/raw?variant=thumb",
"previewUrl": "/api/v1/media/assets/3f7a…/raw?variant=preview",
"kind": "image",
"mimeType": "image/jpeg",
"alt": "A lighthouse at dusk",
"tracks": []
}
],
"total": 1
}
}
}thumbUrl/previewUrlappear when those derivatives exist.altis for this usage: the entry’s own alt, else the asset’s default.""= decorative (the item also has"decorative": true).null= nobody wrote one.tracks— a video or audio asset’s captions:kind,srclang,label,src, optionaldefault. Ready for<track>.- An entry’s media on its own:
GET /api/v1/content/article/9c4b…/media.
Asset URLs need the token
/api/v1/media/assets/:id/raw takes the same bearer token. It cannot go in a
public <img src>. Proxy the bytes through your server, or copy assets to a CDN
at build time. See the media API.
Sibling translations
?translations=preview adds the rest of the translation group. 400 on a
type that is not localized. See reading
entries.
Cost
- Expansion is extra work per row. Three expanded fields on a 25-row page cost far more than none.
- Pattern that works: a listing with
fields=and no expansion, then a detail read that expands what that page needs.
REST expansion is one level deep
relationFields=author returns the author, not the author’s relations. There is
no author.company syntax. Make a second read, or use GraphQL.
What to read next
- The media API — fetching the bytes behind a media URL.
- GraphQL — nested shapes in one query.