Media
Upload, serve, and manage assets via cms.api.media.
Media methods live under cms.api.media and are mirrored on the client as client.media.<method> with identical types. For the model behind them, see the Media concept.
Every method requires a session except the public asset delivery redirect. Uploaded assets are created private; flip them to public with updateAssetsStatus before serving. Each asset row carries a direct object url (${publicUrl}/${objectKey}) for internal/admin display such as a media library; serve assets in content through the public delivery gate instead.
Folders
List Folders
Walk the folder tree one level at a time. Omit parentFolderId to get the root-level folders, or pass a folder id to get that folder's direct children — sorted by name. It's intentionally unpaginated: each call is bounded by a single parent's direct-child fan-out, so you page the tree by descending, not by offset.
media:read/media/listFoldersconst data = await cms.api.media.listFolders({
query: { parentFolderId: 'fld_images' }, // omit for the root-level folders
});const { data, error } = await client.media.listFolders({
query: { parentFolderId: 'fld_images' }, // omit for the root-level folders
});parentFolderIdstringParent folder id. Omit for the root-level folders (those with no parent).
folders{ id, name, parentId, createdBy, createdAt }[]The direct child folders (of the parent, or of the root), sorted by name. `parentId` and `createdBy` are `null` at the root or when unknown.
Create a Folder
Create a folder in the asset library to group your assets. Nest it under a parent with parentFolderId, or omit that to add a folder at the root.
media:create/media/createFolderconst data = await cms.api.media.createFolder({
body: {
name: 'Images', // required
parentFolderId: 'fld_media',
},
});const { data, error } = await client.media.createFolder({
body: {
name: 'Images', // required
parentFolderId: 'fld_media',
},
});namestringrequiredFolder name.
parentFolderIdstringParent folder id. Omit to create a root-level folder. Throws `PARENT_NOT_FOUND` if it does not exist.
folder{ id, name, parentId, createdBy, createdAt }The new folder. `parentId` is `null` for a root-level folder.
Move a Folder
Move a folder under a new parent, or detach it to the root with newParentFolderId: null. Moving a folder into itself or into one of its own descendants is rejected.
media:update/media/moveFolderconst data = await cms.api.media.moveFolder({
body: {
folderId: 'fld_images', // required
newParentFolderId: 'fld_media', // omit or null to move to the root
},
});const { data, error } = await client.media.moveFolder({
body: {
folderId: 'fld_images', // required
newParentFolderId: 'fld_media', // omit or null to move to the root
},
});folderIdstringrequiredThe folder to move.
newParentFolderIdstring | nullNew parent folder id. Omit or pass `null` to move to the root.
folder{ id, name, parentId, createdBy, createdAt }The updated folder, with its new `parentId` (`null` at the root).
Delete a Folder
Delete an empty folder. Clear out its assets and subfolders first — a folder that still holds content can't be deleted.
media:delete/media/deleteFolderconst data = await cms.api.media.deleteFolder({
body: { folderId: 'fld_images' }, // required
});const { data, error } = await client.media.deleteFolder({
body: { folderId: 'fld_images' }, // required
});folderIdstringrequiredThe folder to delete.
folderIdstringThe id of the deleted folder.
Assets
List Assets
List the assets in your media library, with optional filtering (by folder, status, or slug search) and sorting. For paging, prefer the stable cursor (keyset) mode to walk the whole library — pass the previous page's nextCursor to continue; the legacy offset mode is capped to the first 100 rows and cursor takes precedence over it.
media:read/media/listAssetsconst data = await cms.api.media.listAssets({
query: {
status: 'public',
limit: 50,
},
});const { data, error } = await client.media.listAssets({
query: {
status: 'public',
limit: 50,
},
});folderIdstringFilter to a folder's assets. Omit for no folder filter.
unfiledbooleanSet `true` to list only root-level (unfiled) assets, those with no folder. Takes precedence over `folderId`.
status'private' | 'public'Filter by privacy status.
searchstringCase-insensitive substring match against the asset slug.
limitnumber= 20Page size, 1 to 100.
offsetnumber= 0Rows to skip. Ignored when `cursor` is given.
cursorstringOpaque keyset cursor from a previous page's `nextCursor`; pages past the offset ceiling.
sortBy'createdAt' | 'slug' | 'size'= 'createdAt'Sort field.
sortDirection'asc' | 'desc'= 'desc'Sort order.
assetsAssetListItem[]The assets on this page. Each carries `id`, `slug`, `mimeType`, `size`, `objectKey`, direct `url`, `status`, `folderId`, `variantOf`, `uploadedBy`, `createdAt`, `updatedAt`.
totalnumberTotal assets across all pages for the current filter (ignores paging).
hasMorebooleanWhether more assets exist after this page.
nextCursorstring | nullOpaque cursor for the next page; `null` at the end of the list. Pass it back as `cursor` to continue.
Each asset includes the direct url (${publicUrl}/${objectKey}) for internal/admin display; nextCursor is null at the end of the list.
Get Assets by Id
Resolve a batch of assets by id — the id-to-asset counterpart to listAssets, for previewing assets that fall outside the current library page (say, ones referenced by content you're editing). You get back the same row shape as listAssets; archived, out-of-scope, and unknown ids are simply absent from the result (no error), and order is not guaranteed.
media:read/media/getAssetsconst data = await cms.api.media.getAssets({
query: { ids: ['ast_123', 'ast_456'] }, // required
});const { data, error } = await client.media.getAssets({
query: { ids: ['ast_123', 'ast_456'] }, // required
});idsstring[]requiredAsset ids to resolve (at least one).
assetsAssetListItem[]The matching live, in-scope assets, each with the same fields a `listAssets` row carries. Unknown, archived, and out-of-scope ids are absent; order is not guaranteed.
Get Asset Usages
Check where an asset is used across your live content before you touch it — you get each page that references it, with per-block occurrences. Each page reports its rootId, collection, storedSlug (the page's bare stored slug segment, not a full URL path), and the block-level occurrences.
media:read/media/getAssetUsagesconst data = await cms.api.media.getAssetUsages({
query: { assetId: 'ast_123' }, // required
});const { data, error } = await client.media.getAssetUsages({
query: { assetId: 'ast_123' }, // required
});assetIdstringrequiredThe asset id to query. Throws `ASSET_NOT_FOUND` if it does not exist.
pageCountnumberHow many distinct live pages reference the asset.
pages{ rootId, collection, storedSlug, occurrences }[]One entry per referencing page. `storedSlug` is the page's bare stored slug segment (`null` for root pages); `occurrences` is `{ branchId, blockId, propertyKey }[]`.
Uploads
Create a Signed Upload
Hand the browser presigned PUT URLs so it can upload files straight to your bucket — no bytes pass through your server. The asset rows are created up front as private; PUT each file to its signedUrl with the returned headers, then flip the assets to public when you're ready to serve them.
media:create/media/createSignedUploadconst data = await cms.api.media.createSignedUpload({
body: {
files: [{ name: 'hero.jpg', size: 512000, type: 'image/jpeg' }], // required
},
});const { data, error } = await client.media.createSignedUpload({
body: {
files: [{ name: 'hero.jpg', size: 512000, type: 'image/jpeg' }], // required
},
});files{ name: string; size: number; type: string; variantOf?: string }[]requiredFiles to upload (at least one). `size` is the byte length, `type` a MIME type, `variantOf` an existing asset id when uploading a variant.
folderIdstringTarget folder id.
assets{ id, slug, objectKey, url, signedUrl, headers }[]One entry per file: the created (private) asset plus its presigned `signedUrl` and the `headers` to send with the PUT.
expiresAtDateWhen the signed URLs expire.
Returns { assets: { id, slug, objectKey, url, signedUrl, headers }[], expiresAt }. PUT each file to its signedUrl with the returned headers (Content-Type, x-amz-acl: public-read); expiresAt is when the signed URLs expire. Throws TOO_MANY_FILES, FILE_TOO_LARGE, INVALID_FILE_TYPE, FOLDER_NOT_FOUND, ASSET_NOT_FOUND (unknown variantOf), or SLUG_GENERATION_FAILED.
Upload Assets
Upload file bytes through the server — reach for this for small files or server-initiated uploads. It takes the same shape as createSignedUpload, but each file carries its buffer. Declared image types are sniffed against their magic bytes, so a spoofed file (e.g. an SVG posing as image/png) is rejected before any row or object is written.
uploadAssets takes in-process bytes (buffer: Blob | ArrayBuffer) and is server-side only, for the same reason as replaceAsset below: a browser File can't survive the client's JSON request body. It's marked scope: 'server', so client.media.uploadAssets is a compile error, not just a runtime failure — the client's types never offer it. Browsers must use createSignedUpload instead. cms.api.media.uploadAssets is unaffected; this is a client-side type filter only.
media:create/media/uploadAssetsconst data = await cms.api.media.uploadAssets({
body: {
files: [{ name: 'hero.jpg', size: buffer.byteLength, type: 'image/jpeg', buffer }], // required
},
});const { data, error } = await client.media.uploadAssets({
body: {
files: [{ name: 'hero.jpg', size: buffer.byteLength, type: 'image/jpeg', buffer }], // required
},
});files{ name: string; size: number; type: string; buffer: Blob | ArrayBuffer; variantOf?: string }[]requiredFiles with byte content (at least one). The real byte length is measured server-side; the declared `size` is not trusted.
folderIdstringTarget folder id.
assetsAssetListItem[]The uploaded assets, each with the same fields a `listAssets` row carries.
Returns { assets }, each with the same fields a listAssets row carries. Throws TOO_MANY_FILES, FILE_TOO_LARGE, INVALID_FILE_TYPE, FOLDER_NOT_FOUND, ASSET_NOT_FOUND (unknown variantOf), UPLOAD_FAILED, or SLUG_GENERATION_FAILED.
Replace an Asset
Swap the bytes behind an existing asset while keeping its id (and folderId/status) stable, so every content reference picks up the new image with no content change — the id-addressed gate re-resolves it. A fresh slug and object key are minted for a clean cache-bust, and the asset's old variants are archived (regenerate them afterward). Server-side and atomic.
replaceAsset takes in-process bytes (buffer: Blob | ArrayBuffer) and is for server-side callers only — a browser File cannot survive the client's JSON request body (it serializes to {}). It's marked scope: 'server', so client.media.replaceAsset is a compile error, not just a runtime failure — the client's types never offer it. Browsers must use createSignedReplace + commitReplace instead, or the useReplaceAsset client hook that wraps them. cms.api.media.replaceAsset is unaffected; this is a client-side type filter only.
media:update/media/replaceAssetconst data = await cms.api.media.replaceAsset({
body: {
assetId: 'ast_123', // required
file: { name: 'hero.jpg', size: buffer.byteLength, type: 'image/jpeg', buffer }, // required
},
});const { data, error } = await client.media.replaceAsset({
body: {
assetId: 'ast_123', // required
file: { name: 'hero.jpg', size: buffer.byteLength, type: 'image/jpeg', buffer }, // required
},
});assetIdstringrequiredThe asset to replace.
file{ name: string; size: number; type: string; buffer: Blob | ArrayBuffer }requiredThe new file bytes. Sniffed like `uploadAssets`. Server-side only — see the callout above.
assetAssetListItemThe updated asset (same `id`, new `slug`/`objectKey`/`url`), with the same fields a `listAssets` row carries.
Returns { asset } with the same fields a listAssets row carries. Throws CANNOT_REPLACE_VARIANT (target is itself a variant), ASSET_NOT_FOUND, FILE_TOO_LARGE / INVALID_FILE_TYPE, or UPLOAD_FAILED (asset left unchanged).
Create a Signed Replace
The browser-callable half of a replace: validates the target asset (live, in scope, not itself a variant) and the new file's declared metadata, mints a new slug/object key, and returns a presigned PUT URL — the same shape as createSignedUpload, but for an existing asset. It does not touch the database; call commitReplace once the PUT succeeds.
media:update/media/createSignedReplaceconst data = await cms.api.media.createSignedReplace({
body: {
assetId: 'ast_123', // required
file: { name: 'hero-v2.jpg', size: file.size, type: file.type }, // required
},
});const { data, error } = await client.media.createSignedReplace({
body: {
assetId: 'ast_123', // required
file: { name: 'hero-v2.jpg', size: file.size, type: file.type }, // required
},
});assetIdstringrequiredThe asset to replace.
file{ name: string; size: number; type: string }requiredThe new file's declared metadata — no bytes; the browser PUTs them straight to your bucket.
assetIdstringEchoes the requested asset id — pass it through to `commitReplace`.
slugstringThe freshly minted slug (cache-bust). Pass it through to `commitReplace`.
objectKeystringThe freshly minted object key (equal to `slug`). Pass it through to `commitReplace`.
urlstringThe object's public URL once the PUT below succeeds AND `commitReplace` repoints the row. For internal/admin display only.
signedUrlstringPresigned PUT URL — upload the new bytes here.
headersRecord<string, string>Headers to send with the PUT (`Content-Type`, `x-amz-acl: public-read`).
expiresAtDateWhen the signed URL expires.
Throws ASSET_NOT_FOUND (missing, archived, or out of scope), CANNOT_REPLACE_VARIANT, FILE_TOO_LARGE / INVALID_FILE_TYPE, or SLUG_GENERATION_FAILED.
Commit a Replace
Repoints the asset's row at the new object after the browser's PUT to createSignedReplace's signedUrl has succeeded — the second half of the browser-callable replace flow. Atomic and TOCTOU-safe like replaceAsset: the row is repointed and old variants archived in one transaction, and it keeps every guarantee replaceAsset has (including tombstoning the superseded object so it becomes reclaimable — see Object lifecycle).
media:update/media/commitReplaceconst data = await cms.api.media.commitReplace({
body: {
assetId: 'ast_123', // required
objectKey: signed.objectKey, // required — from createSignedReplace
slug: signed.slug, // required — from createSignedReplace
mimeType: file.type, // required
size: file.size, // required
},
});const { data, error } = await client.media.commitReplace({
body: {
assetId: 'ast_123', // required
objectKey: signed.objectKey, // required — from createSignedReplace
slug: signed.slug, // required — from createSignedReplace
mimeType: file.type, // required
size: file.size, // required
},
});assetIdstringrequiredThe asset being replaced (must match the `createSignedReplace` call).
objectKeystringrequiredThe `objectKey` returned by `createSignedReplace`.
slugstringrequiredThe `slug` returned by `createSignedReplace`.
mimeTypestringrequiredThe uploaded file's declared MIME type.
sizenumberrequiredThe uploaded file's declared byte size.
assetAssetListItemThe updated asset (same `id`, new `slug`/`objectKey`/`url`), with the same fields a `listAssets` row carries.
Returns { asset } with the same fields a listAssets row carries. Throws ASSET_NOT_FOUND if the asset was archived or left scope since createSignedReplace (TOCTOU) — the row is left unchanged and the just-uploaded object is tombstoned for reclaim rather than abandoned.
// Full browser-side replace flow.
const signed = await cmsClient.media.createSignedReplace({
body: { assetId: hero.id, file: { name: file.name, size: file.size, type: file.type } },
});
await fetch(signed.signedUrl, { method: 'PUT', headers: signed.headers, body: file });
const { asset } = await cmsClient.media.commitReplace({
body: {
assetId: hero.id,
objectKey: signed.objectKey,
slug: signed.slug,
mimeType: file.type,
size: file.size,
},
});The React client wraps this pipeline as client.media.useReplaceAsset() (mirrors useUploadAssets) — const { replace, isReplacing, progress, error, result } = client.media.useReplaceAsset(); await replace(assetId, file);. The vanilla client exposes the same state as a raw nanostores atom at client.media.replaceState.
Asset status
Update Asset Status
Flip one or more assets between private and public — this is the gate you open to actually serve an uploaded asset. Archived and out-of-scope ids come back in skipped; if none of the ids match a live, in-scope asset the call throws ASSET_NOT_FOUND.
media:update/media/updateAssetsStatusconst data = await cms.api.media.updateAssetsStatus({
body: {
assetIds: ['ast_123'], // required
status: 'public', // required
},
});const { data, error } = await client.media.updateAssetsStatus({
body: {
assetIds: ['ast_123'], // required
status: 'public', // required
},
});assetIdsstring[]requiredAsset ids to update (at least one).
status'private' | 'public'requiredTarget status.
updatednumberHow many assets had their status changed.
updatedIdsstring[]The ids that were updated.
skippedstring[]Requested ids that matched no live, in-scope asset (archived or out of scope).
Move Assets
Move a batch of assets into a folder (or to the root with folderId: null). Non-existent, out-of-scope, archived, and variant ids are skipped (surfaced in skipped), so a batch partially succeeds. A moved asset's variants follow it into the same folder, so an original and its variants are never split apart.
media:update/media/moveAssetsconst data = await cms.api.media.moveAssets({
body: {
assetIds: ['ast_123'], // required
folderId: 'fld_images', // required; null moves to the root
},
});const { data, error } = await client.media.moveAssets({
body: {
assetIds: ['ast_123'], // required
folderId: 'fld_images', // required; null moves to the root
},
});assetIdsstring[]requiredAsset ids to move (at least one).
folderIdstring | nullrequiredTarget folder id, or `null` to move to the root.
movednumberHow many originals were moved (their variants follow along).
movedIdsstring[]The ids that were moved.
skippedstring[]Requested ids that were skipped: missing, out-of-scope, archived, or a variant passed on its own.
Throws FOLDER_NOT_FOUND (unknown/out-of-scope target) or ASSET_NOT_FOUND (no live ids).
Archive Assets
Soft-delete a batch of assets (and their variants) so a later garbage-collection pass can reclaim them. Assets still referenced by live (published) content are skipped rather than failed, so the rest of the batch still archives. Throws ASSET_NOT_FOUND if none of the ids match a live, in-scope asset.
media:delete/media/archiveAssetsconst data = await cms.api.media.archiveAssets({
body: { assetIds: ['ast_123'] }, // required
});const { data, error } = await client.media.archiveAssets({
body: { assetIds: ['ast_123'] }, // required
});assetIdsstring[]requiredAsset ids to archive (at least one).
archivednumberHow many assets were archived (variants archived alongside them).
archivedIdsstring[]The ids that were archived.
skippedstring[]Ids skipped because they are still referenced by live content.
Public delivery
Serve an Asset
Serve an asset on a public page by addressing it by asset id (what content stores) — this 302s to the asset's current object URL. It's what you put behind an <img src>, and it survives a replaceAsset untouched: the gate just re-resolves the id to the current object. Private assets are rejected with ASSET_ACCESS_DENIED, and the redirect is short-cached (max-age=300, not immutable), so swapping the object behind the same id propagates automatically.
/media/asset/{assetId}const data = await cms.api.media.asset({
params: { assetId: 'ast_123' }, // required
query: { w: 800, format: 'webp' },
});const { data, error } = await client.media.asset({
params: { assetId: 'ast_123' }, // required
query: { w: 800, format: 'webp' },
});assetIdstringrequiredPath parameter: the asset id to serve.
format'webp' | 'jpeg' | 'png'Serve a pre-uploaded format variant, falling back to the original.
wnumberServe a pre-uploaded width variant (positive integer), falling back to the original.
downloadbooleanSet `true` to send a `Content-Disposition: attachment` header.
Location302 redirectA `302` to the asset's public object URL (short-cached, `max-age=300`). No JSON body: the browser follows the `Location` header to the bytes.