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 one row per asset in the CMS database. The CMS handles uploads, access control, folders, and variants; serving happens from your bucket or a CDN. For exact method signatures, see the media reference.
The asset model
Each upload creates a row in assets:
| Field | Meaning |
|---|---|
id | Stable primary key (ast_…). What content stores and what the gate resolves. Never changes, even when the bytes are replaced. |
slug | Unique, URL-friendly name derived from the filename. Equal to the objectKey. Regenerated on replaceAsset. |
objectKey | The key in your bucket (currently the slug verbatim). |
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. |
Three of these are identifiers, and keeping them straight is the key to the whole model. The id is stable and opaque: content references it, and the gate is addressed by it. The slug is a human-readable, unique name derived from the filename, and it doubles as the objectKey in your bucket. The two are decoupled on purpose. replaceAsset (server-side) or createSignedReplace + commitReplace (browser) mints a new slug and object key for the new bytes but keeps the id, so every reference follows the swap while the CDN still treats each version's object as immutable.
Folders are a nested tree (asset_folders, each with a parentId). They organize the media library and nothing more: a folder does not affect an asset's URL, its status, or how it is served. listFolders reads one level at a time (the children of a parent, or the root-level folders when no parentFolderId is given); createFolder, moveFolder, and deleteFolder manage them. A folder that still holds assets or subfolders cannot be deleted (it throws FOLDER_HAS_CONTENT), and moving an asset carries its variants along, so an original and its variants are never split across folders.
Private vs public
status is a visibility flag. An asset is created private, and a private asset is not served by the public gate: GET /media/asset/{id} returns ASSET_ACCESS_DENIED. To expose one, flip it to public with updateAssetsStatus. You rarely call that by hand: publishing content that references an asset flips it to public automatically, and unpublishing reverts it to private unless another published page still references it. Status tracks what your live content needs, so it mostly manages itself.
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." As defense in depth, serve assets from a cross-origin host (a publicUrl that is not your app's origin) so an uploaded file cannot run as same-origin, and note that the default allowedMimeTypes excludes SVG for the same reason. See Security → Media privacy.
Uploading: signed URL vs server
Two upload paths write the same rows and enforce the same limits; they differ only in who holds the bytes:
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; the ReactuseUploadAssetshook wraps it.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, and an allowedMimeTypes allowlist that excludes SVG; see Configuration), and signed URLs expire after 120s by default. Every new asset lands private, so uploading and serving are two steps:
// Server-side upload: bytes go through the CMS to the bucket.
const { assets } = await cms.api.media.uploadAssets({
body: {
files: [
{ name: 'welcome-hero.jpg', size: buffer.byteLength, type: 'image/jpeg', buffer },
],
},
});
const [hero] = assets; // { id: 'ast_…', slug: 'welcome-hero.jpg', status: 'private', … }
// New assets are private. Publishing referencing content flips this for you;
// to serve an asset on its own, make it public explicitly.
await cms.api.media.updateAssetsStatus({
body: { assetIds: [hero.id], status: 'public' },
});
// Now it serves through the gate, addressed by its id:
// <img src={`/media/asset/${hero.id}`} />The browser hook is covered step by step in Upload and serve media.
Replacing: signed URL vs server
Swapping an existing asset's bytes has the same two-path split as uploading, for the same reason — a browser cannot send raw file bytes through a JSON request body:
createSignedReplace+commitReplace(browser).createSignedReplacevalidates the target and mints a signed PUT URL without touching the row; the browser PUTs the bytes straight to the bucket;commitReplacerepoints the row once that succeeds. The ReactuseReplaceAssethook wraps both calls.replaceAsset(server-side only). Takes the file bytes directly (buffer: Blob | ArrayBuffer) in one call. A browserFilecannot survivereplaceAsset's JSON body — it serializes to{}— so this endpoint is for server-initiated replacements only.
Both mint a fresh slug/object key (the cache-bust) and keep the asset's id stable, exactly like replaceAsset described above.
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, falling back to the original.
There is no on-the-fly transform. A variant is just another asset row whose variantOf points at the original and whose slug is derived from the original plus the requested format and width. The gate computes that expected slug and looks it up: a hit is served, a miss silently serves the original. So a request for /media/asset/{id}?w=800&format=webp on an asset stored as welcome-hero.jpg resolves to a sibling asset named welcome-hero-800-webp.webp:
// Register a pre-sized WebP variant of an existing asset.
await cms.api.media.createSignedUpload({
body: {
files: [
{
name: 'welcome-hero-800-webp.webp', // matches the slug the gate derives
size: variantBytes,
type: 'image/webp',
variantOf: hero.id,
},
],
},
});
// GET /media/asset/{hero.id}?w=800&format=webp now serves this variant;
// any other size or format still falls back to the original.Create the variants you need ahead of time. The media optimize plugin resizes and re-encodes images in the browser before upload, so the stored bytes are already small.
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, and ?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. Do not 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. Do not store the direct url in content: it bypasses the status gate and transforms, pins the entry to your bucket layout, and will not 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:
// Which live pages reference this asset?
const { pageCount, pages } = await cms.api.media.getAssetUsages({
query: { assetId: hero.id },
});Object lifecycle
Archiving an asset (archiveAssets) is soft-delete: the row gets archivedAt set and drops out of listAssets/the gate, but the row and its S3 object both stay put until a later pruning pass reclaims them — once it is past the trash window (archiveKeepDays) and no live content references it.
Replacing an asset (either path above) mints a new object for the new bytes but keeps the row's id, so the row that used to name the old object no longer exists after the swap — nothing would ever reference the superseded object for reclaim. To close that gap, a replace also inserts a tombstone: a fresh, already-archived asset row that reuses the old slug/object key (with a brand-new id, never referenced by any content). That tombstone is what the pruning pass actually finds and reclaims once its trash window elapses — so a superseded object is not left behind forever, and replacing the same asset repeatedly does not accumulate orphaned bytes in your bucket.
Limitations
The media pipeline is deliberately thin. Know these boundaries before you design around it:
- No server-side image transforms.
GET /media/asset/{id}?format=webp&w=800resolves a pre-uploaded variant (an asset whosevariantOfpoints at the original) by matching the requested format and width. If no matching variant exists, the gate silently serves the original, at its stored size and format: an unmatched?formator?wis not an error and does not resize or convert on the fly. Create the variants you need ahead of time. - Optimization is client-side only. The media optimize plugin resizes and re-encodes each image in the browser (via a canvas) before upload, then uploads the smaller result. Nothing optimizes server-side, so a file uploaded through a path that skips the plugin is stored as-is.
- Assets store bytes and status, nothing else. An
assetsrow carries the fields in the table above and no more. There is no stored width/height, focal point, alt text, or caption. Keep intrinsic dimensions, art-direction focal points, and accessibility text on your content instead (for example animageblock's ownaltproperty), not on the asset.
For uploading and serving step by step, see Upload and serve media.