field.media attaches Media Library assets to an
entry.
export const article = collection('article', {
publishable: true,
fields: {
title: field.text({ required: true }),
coverImage: field.media({
required: true,
accept: { kinds: ['image'] },
admin: { label: 'Cover image' }
}),
gallery: field.media({ multiple: true, accept: { mimeTypes: ['image/*'] } }),
brochure: field.media({ accept: { mimeTypes: ['application/pdf'] } })
}
});| Option | Default | Effect |
|---|---|---|
multiple | false | An ordered list of assets instead of one. |
accept | any asset | { kinds?, mimeTypes? } — which assets may be attached. |
required | false | Must hold an asset. To publish, each asset also needs alt text or decorative: true. |
localized | false | Different asset(s) per locale. |
The value
An asset id, or the id plus what this usage says about it:
{
"values": {
"coverImage": { "id": "3f0c…", "alt": "The harbour at dusk" },
"gallery": [
{ "id": "3f0c…", "alt": "The harbour at dusk" },
{ "id": "9a21…", "decorative": true },
"c7d2…"
]
}
}- Alt text is per usage: the same logo can be “Acme logo” in a header and decorative in a footer.
decorative: truepublishes with an emptyalt. It is a deliberate answer, not a missing one.- A bare id is accepted and means “no alternative supplied”.
Rules, checked on every write to a non-publishable type and at publish on a publishable one:
altis a string of at most 1,000 characters.alttogether withdecorative: trueis refused.- On a required field every asset needs non-blank
altordecorative: trueto publish.
Writing media
Upload first, then put the asset id in values. Uploading over the public API
needs a full token (media:create).
# 1. Upload — multipart, field name "file"
curl -X POST https://cms.example.com/api/v1/media/assets \
-H 'Authorization: Bearer orthacms_…' \
-F 'file=@harbour.jpg'
# → { "id": "3f0c…", … }
# 2. Attach it
curl -X PATCH https://cms.example.com/api/v1/content/article/0b9e… \
-H 'Authorization: Bearer orthacms_…' \
-H 'Content-Type: application/json' \
-d '{ "values": { "coverImage": { "id": "3f0c…", "alt": "The harbour at dusk" } } }' mutation {
updateArticle(id: "0b9e…", input: { coverImage: "3f0c…", gallery: ["3f0c…", "9a21…"] }) {
id
}
} The GraphQL input takes asset ids only (ID or [ID!]); per-usage alt text is
set through REST or the admin.
Reading media
Media fields are not in a public read’s values. Expand them:
curl 'https://cms.example.com/api/v1/content/article/0b9e…?media=preview&mediaFields=coverImage,gallery&mediaLimit=10' \
-H 'Authorization: Bearer orthacms_…' query {
article(id: "0b9e…") {
title
coverImage { url thumbUrl alt decorative mimeType }
gallery(limit: 10) { url alt }
}
} {
"id": "0b9e…",
"values": { "title": "Harbour walks" },
"media": {
"coverImage": {
"items": [{
"id": "3f0c…",
"name": "harbour.jpg",
"url": "/api/v1/media/assets/3f0c…/raw",
"thumbUrl": "/api/v1/media/assets/3f0c…/raw?variant=thumb",
"previewUrl": "/api/v1/media/assets/3f0c…/raw?variant=preview",
"kind": "image",
"mimeType": "image/jpeg",
"alt": "The harbour at dusk",
"tracks": []
}],
"total": 1
}
}
} urlneeds the same bearer token as the read. A browser<img src>sends none, so proxy or re-serve the bytes from the server holding the token.altis the usage’s own, else the asset’s default.""means decorative (renderalt="");nullmeans nobody supplied one — a gap to report.totalcounts every attached asset;mediaLimit(default 20, max 100) only capsitems.thumbUrl/previewUrlappear only when the derivative exists.GET /api/v1/content/:type/:id/mediareturns the same, for all media fields.- In the admin,
GET /api/content/:type/:id/mediaresolves the same way; a deleted asset stays in place as amissingreference.
See expanding relations and media.
How it is stored
| Field | Column |
|---|---|
| single | uuid('<field>') |
multiple: true | jsonb('<field>') array |
Values live in the entry’s values bag, not a join table. So they are in every
revision snapshot, sync across locale siblings like
any shared field, and are per-locale when localized.
No foreign key
Asset ids are plain uuids — the assets live in the media plugin’s schema, which
content cannot reference. Existence and accept are checked in application
code on every write. Without a media plugin, media fields still shape-check
and store; they skip the existence and accept checks.
accept
Kinds and MIME patterns combine as OR: an asset passes if it matches any listed kind or any listed pattern. No lists → anything.
field.media({ accept: { kinds: ['image'] } }) // any image
field.media({ accept: { mimeTypes: ['image/png', 'application/pdf'] } }) // exact types
field.media({ accept: { kinds: ['image'], mimeTypes: ['application/pdf'] } }) // images, plus PDFs- Kinds:
image,video,audio,document,archive. Unknown kinds fail at boot. - A MIME pattern is exact (
application/pdf) or a wildcard (image/*).
Errors
Every failure is a 422 naming the field:
{
"message": "Entry validation failed",
"issues": [
{ "field": "coverImage", "message": "must reference an existing asset" },
{ "field": "brochure", "message": "must be an allowed asset (application/pdf)" }
]
}A missing asset and one in another workspace give the same message, so
ids cannot be probed. Once the caller can see the asset, an accept failure
says what the field wants.