Features Apograph CMS on GitHub

Media fields

Media plugin @apograph/media-server@apograph/media-admin

How an entry points at library assets, and what restricts which ones it may point at.

Documents 0.5.2 Updated Edit this page Report a problem

On this page

field.media attaches assets from the Media Library to a content type.

fields: {
    coverImage: field.media({
        accept: { kinds: ['image'] },
        admin: { label: 'Cover image' }
    }),
    gallery: field.media({
        multiple: true,
        accept: { mimeTypes: ['image/*'] }
    })
}
OptionDefaultEffect
multiplefalseHold an ordered list of assets rather than one.
acceptany assetRestrict which assets may be attached.
requiredfalseThe field must hold an asset.
localizedfalseThe attachment differs per locale.

How it is stored

A single media field is a uuid('<field>') column. A multiple: true field is a jsonb('<field>') array of ids, mirroring how multiselect stores its values.

Both live in the values bag, not in a join table, and that has a set of consequences that all fall out for free:

  • Media values are captured by the revision snapshot, so version history includes what was attached.
  • They are synced across locale siblings when the field is shared, and are per-locale when it is marked localized — exactly like a scalar.
  • They travel in the same values object as everything else on the API.

No foreign key

Asset ids are plain uuids with no Postgres foreign key. The assets live in the media plugin’s own schema, and content-server cannot reference another plugin’s tables — the same convention as workspace_id.

Existence and the accept restriction are enforced in the application layer instead, on every write. What that buys you is a system where the media plugin can be absent entirely: with no media plugin bound, media fields still shape-check (is this a uuid?) and still store, they just skip the existence and restriction checks.

accept

Both filters are optional, and they combine as OR within each list. An asset passes when it matches any listed kind or any listed MIME pattern. An accept with neither list accepts anything.

// Any image
field.media({ accept: { kinds: ['image'] } })

// PNGs and PDFs specifically
field.media({ accept: { mimeTypes: ['image/png', 'application/pdf'] } })

// Any image, plus PDFs
field.media({
    accept: { kinds: ['image'], mimeTypes: ['application/pdf'] }
})

The five coarse kinds are image, video, audio, document and archive. An unknown kind is rejected at define time. A MIME pattern is either exact (application/pdf) or a type/* wildcard (image/*).

What a failed check looks like

An asset that does not exist, one that belongs to another workspace, and one that accept disallows all produce the same 422.

That uniformity is deliberate. Answering “no such asset” differently from “not allowed here” would tell a caller whether an id they cannot otherwise see is real, which is an enumeration signal. Every relation and media target check in the system behaves this way.

Reading assets back

An entry’s stored value is a bare id — or an array of them. To turn those into something displayable:

  • In the admin, GET /api/content/:type/:id/media resolves an entry’s media ids to display references: name, thumbnail URL and kind.
  • On the public API, add ?media=preview&mediaFields=coverImage,gallery to a read. See expanding relations and media.

Both go through the same query, so the admin and a public consumer see the same resolution rules.