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

Media fields

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

How an entry points at library assets, what restricts which ones it may point at, and how to read them back.

Documents 0.13.1 Updated Edit this page Report a problem

On this page

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'] } })
    }
});
OptionDefaultEffect
multiplefalseAn ordered list of assets instead of one.
acceptany asset{ kinds?, mimeTypes? } — which assets may be attached.
requiredfalseMust hold an asset. To publish, each asset also needs alt text or decorative: true.
localizedfalseDifferent 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: true publishes with an empty alt. 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:

  • alt is a string of at most 1,000 characters.
  • alt together with decorative: true is refused.
  • On a required field every asset needs non-blank alt or decorative: true to publish.

Writing media

Upload first, then put the asset id in values. Uploading over the public API needs a full token (media:create).

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

Example language
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
      }
  }
}
  • url needs 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.
  • alt is the usage’s own, else the asset’s default. "" means decorative (render alt=""); null means nobody supplied one — a gap to report.
  • total counts every attached asset; mediaLimit (default 20, max 100) only caps items.
  • thumbUrl / previewUrl appear only when the derivative exists.
  • GET /api/v1/content/:type/:id/media returns the same, for all media fields.
  • In the admin, GET /api/content/:type/:id/media resolves the same way; a deleted asset stays in place as a missing reference.

See expanding relations and media.

How it is stored

FieldColumn
singleuuid('<field>')
multiple: truejsonb('<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.