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

Expanding relations and media

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

Opt-in expansion of relation and media fields, and how to page past the cap.

Documents 0.13.1 Updated Edit this page Report a problem

On this page

A read returns scalars. Relations and media come back only when you ask.

Example language
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

ParameterEffect
relations=previewTurns expansion on.
relationFieldsFields to expand, comma-separated. Omit to expand every readable relation field.
relationLimitLinks 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].
  • total is the count of visible links. Compare it to items.length to decide whether to page.
  • Only published targets are shown or counted — even under ?status=any. Drafts, soft-deleted targets and targets restricted by X-Reader-Tags are missing from both items and total.
  • 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).

Example language
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=de

Media

ParameterEffect
media=previewTurns media expansion on.
mediaFieldsFields to expand, comma-separated. Omit for every media field.
mediaLimitAssets 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 / previewUrl appear when those derivatives exist.
  • alt is 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, optional default. 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.