Media
How assets, folders, uploads, and serving work.
Media (images, video, PDFs) lives in object storage (S3, Cloudflare R2, DigitalOcean Spaces, or any S3-compatible bucket), with a row per asset in the CMS database. The CMS handles uploads, access control, folders, and variants; serving happens from your bucket or a CDN.
The asset model
Each upload creates a row in assets:
| Field | Meaning |
|---|---|
slug | Unique, URL-friendly id. Equal to the objectKey. |
objectKey | The key in your bucket. |
status | 'private' (default) or 'public'. |
folderId | The folder it lives in, if any. |
variantOf | If set, this asset is a variant (a resized or reformatted copy) of another. |
mimeType, size | Content type and byte size. |
archivedAt | Soft-delete marker for pruning. |
Folders are a nested tree (asset_folders, each with a parentId). listFolders reads them — the children of a parent, or the root-level folders when no parentId is given; createFolder, moveFolder, and deleteFolder manage them; a folder that still holds assets or subfolders cannot be deleted (FOLDER_HAS_CONTENT).
Private vs public
status is a visibility flag. An asset is created private, and a private asset is not served by the public redirect (GET /media/asset/{id} returns ASSET_ACCESS_DENIED). To expose one, flip it to public with updateAssetStatus — publishing content that references an asset does this automatically.
The gate is enforced by the CMS endpoints, not by the object store — uploaded objects are public-read, so status is not a hard-privacy boundary. Treat it as "should this be shown," not "is this secret."
Uploading: signed URL vs server
There are two upload paths:
createSignedUpload(default). The CMS creates the asset rows and returns presigned PUT URLs; the browser uploads each file straight to the bucket. No file bytes pass through your server, so there are no serverless body-size limits and less egress. Use this for browser uploads.uploadAssets. You send the file bytes (abuffer: Blob | ArrayBufferper file) to the CMS, which PUTs them to the bucket. Use this for server-side sources.
Both validate against the limits (defaults: maxFiles 10, maxFileSize 4 MB, allowedMimeTypes ['image/*', 'video/*', 'application/pdf']), and signed URLs expire after 120s by default. The browser hook is in Upload and serve media.
Serving assets
Serve assets through the gate: GET /media/asset/{id}, a public redirect to the object, addressed by the asset id (which is what content stores). It is the contract to use in content and on public pages — it enforces status (a private asset returns ASSET_ACCESS_DENIED) and honors ?format=webp|jpeg|png and ?w=<width> by resolving a pre-uploaded variant (an asset whose variantOf points at the original), falling back to the original. There is no on-the-fly transform: create variants ahead of time, for example with the media optimize plugin, which optimizes in the browser before upload.
The redirect itself is short-cached (max-age=300, not immutable): because it is keyed by the stable id, swapping the bytes behind that id (keeping the id) re-resolves and propagates to already-rendered pages within minutes — while the object bytes stay long-cached at the CDN, since each version has its own object key.
Deployment note: a CDN in front of the gate must include the query string in its cache key. The redirect target differs per ?format/?w/?download, and only the query string distinguishes those cache entries — if the CDN strips it (a common default), the variant, download, and original redirects collapse into one and get cross-served.
Each asset also has a direct object URL, ${publicUrl}/${objectKey} — returned as url by listAssets and the upload responses. It bypasses the gate (no status check, no transforms), so it is meant for internal tooling such as a media-library admin UI rendering thumbnails of assets you already manage. Don't bake it into content or public pages — serve those through the gate.
Images in content
An image block property stores the asset id (an ast_… reference), never a URL — and the read path returns it verbatim (nothing is resolved). Your renderer builds the gate URL straight from that id: <img src="/media/asset/{id}">. Because the id is stable, swapping the object behind it updates every reference automatically, with no content change and no re-render. Don't store the direct url in content — it bypasses the status gate and transforms and pins the entry to your bucket layout, and it won't follow such a swap. getAssetUsages reports which entries reference an asset (the index keys on the asset id), and archiving is blocked while an asset is used by live content.
For uploading and serving step by step, see Upload and serve media.