Features Apograph CMS on GitHub

Expanding relations and media

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

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

Documents 0.5.2 Updated Edit this page Report a problem

On this page

A read returns scalars. Relations and media come back only when you ask, because the cost scales with the number of expanded fields and most reads want neither.

Example language
curl -G 'https://cms.example.com/api/v1/content/article/9c4b…' \
-H 'Authorization: Bearer apograph_…' \
-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.APOGRAPH_TOKEN } }
);
const entry = await response.json();
const author = entry.relations.author.items[0];
const cover = entry.media.coverImage.items[0];
query Article($id: ID!) {
  article(id: $id) {
      id
      title
      author {
          name
      }
      tags(pageSize: 20) {
          total
          items {
              id
              title
          }
      }
      coverImage {
          url
          alt
      }
  }
}

Relations

ParameterEffect
relations=previewTurns expansion on. Without it, no relation data is returned.
relationFieldsComma-separated relation fields to expand.
relationLimitLinks per expanded field, 1–100. Defaults to 20.

Each expanded field returns one capped page of links plus the count of visible ones:

{
    "id": "9c4b…",
    "values": { "title": "…" },
    "relations": {
        "tags": {
            "items": [{ "id": "1f9a…", "title": "Postgres" }],
            "total": 34
        }
    }
}

total is always the true count, not the number returned. Lowering relationLimit therefore never hides the fact that more exist — which is what lets a client decide whether to page without a second request to find out.

Only published targets are shown or counted. A draft or soft-deleted target is invisible to a public read, and it is not in the total either.

Naming a field that is not a relation, or one whose target type the workspace was not granted, is a 400.

Paging past the cap

When total exceeds what you got, page the field on its own route:

GET /api/v1/content/article/9c4b…/relations/tags?page=2&pageSize=50

There is a group-addressed twin for localized types:

GET /api/v1/content/article/group/:groupId/relations/tags?locale=de

Media

ParameterEffect
media=previewTurns media expansion on.
mediaFieldsComma-separated media fields to expand.
mediaLimitAssets per expanded field, 1–100. Defaults to 20.

Expanded media returns asset metadata plus URLs under /api/v1/media/assets/:id/raw, which take the same bearer token as the request that produced them.

{
    "media": {
        "coverImage": {
            "items": [
                {
                    "id": "3f7a…",
                    "name": "hero.jpg",
                    "kind": "image",
                    "mimeType": "image/jpeg",
                    "url": "/api/v1/media/assets/3f7a…/raw",
                    "alt": "A lighthouse at dusk"
                }
            ],
            "total": 1
        }
    }
}

total tells the truth here too.

An entry’s media can also be fetched on its own:

GET /api/v1/content/article/9c4b…/media

Asset URLs are not public

/api/v1/media/assets/:id/raw requires the bearer token. These are not URLs you can put in a public <img src> — a browser will not send your token, and you do not want it to. Either proxy the bytes through your own server, or copy assets to a CDN at build time. See the media API.

Sibling translations

?translations=preview

Attaches the rest of the entry’s translation group. A 400 on a type that is not localized. See reading entries.

Cost

Each expansion is additional work per row. Expanding three relation fields on a 25-row page is meaningfully more expensive than the same page without them, and relationLimit=100 on a listing is rarely what you want.

The shape that usually works: a listing read with fields= narrowing to what the cards show and no expansion at all, then a detail read that expands what that one page needs.

Expansion is one level deep

relationFields=author returns the author’s summary. It does not return the author’s own relations, and there is no syntax for author.company. A second read is the answer — or GraphQL, where you select the shape you want in one query.

  • The media API — fetching the bytes behind an expanded media field, and why the URL needs the token.
  • GraphQL — selecting a nested shape in one query, where REST expansion stops at one level.