⚠️ Work in progress — createCMS is pre-1.0 and not production-ready (not tested in production). Expect breaking changes.
createCMS
Reference

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
GET/media/listFolders
const { data, error } = await client.media.listFolders({
  query: { parentFolderId: 'fld_images' }, // omit for the root-level folders
});
Parameters
parentFolderIdstring

Parent folder id. Omit for the root-level folders (those with no parent).

Returns
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
POST/media/createFolder
const { data, error } = await client.media.createFolder({
  body: {
    name: 'Images', // required
    parentFolderId: 'fld_media',
  },
});
Parameters
namestringrequired

Folder name.

parentFolderIdstring

Parent folder id. Omit to create a root-level folder. Throws `PARENT_NOT_FOUND` if it does not exist.

Returns
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
POST/media/moveFolder
const { data, error } = await client.media.moveFolder({
  body: {
    folderId: 'fld_images', // required
    newParentFolderId: 'fld_media', // omit or null to move to the root
  },
});
Parameters
folderIdstringrequired

The folder to move.

newParentFolderIdstring | null

New parent folder id. Omit or pass `null` to move to the root.

Returns
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
POST/media/deleteFolder
const { data, error } = await client.media.deleteFolder({
  body: { folderId: 'fld_images' }, // required
});
Parameters
folderIdstringrequired

The folder to delete.

Returns
folderIdstring

The 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
GET/media/listAssets
const { data, error } = await client.media.listAssets({
  query: {
    status: 'public',
    limit: 50,
  },
});
Parameters
folderIdstring

Filter to a folder's assets. Omit for no folder filter.

unfiledboolean

Set `true` to list only root-level (unfiled) assets, those with no folder. Takes precedence over `folderId`.

status'private' | 'public'

Filter by privacy status.

searchstring

Case-insensitive substring match against the asset slug.

limitnumber= 20

Page size, 1 to 100.

offsetnumber= 0

Rows to skip. Ignored when `cursor` is given.

cursorstring

Opaque 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.

Returns
assetsAssetListItem[]

The assets on this page. Each carries `id`, `slug`, `mimeType`, `size`, `objectKey`, direct `url`, `status`, `folderId`, `variantOf`, `uploadedBy`, `createdAt`, `updatedAt`.

totalnumber

Total assets across all pages for the current filter (ignores paging).

hasMoreboolean

Whether more assets exist after this page.

nextCursorstring | null

Opaque 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
GET/media/getAssets
const { data, error } = await client.media.getAssets({
  query: { ids: ['ast_123', 'ast_456'] }, // required
});
Parameters
idsstring[]required

Asset ids to resolve (at least one).

Returns
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
GET/media/getAssetUsages
const { data, error } = await client.media.getAssetUsages({
  query: { assetId: 'ast_123' }, // required
});
Parameters
assetIdstringrequired

The asset id to query. Throws `ASSET_NOT_FOUND` if it does not exist.

Returns
pageCountnumber

How 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
POST/media/createSignedUpload
const { data, error } = await client.media.createSignedUpload({
  body: {
    files: [{ name: 'hero.jpg', size: 512000, type: 'image/jpeg' }], // required
  },
});
Parameters
files{ name: string; size: number; type: string; variantOf?: string }[]required

Files to upload (at least one). `size` is the byte length, `type` a MIME type, `variantOf` an existing asset id when uploading a variant.

folderIdstring

Target folder id.

Returns
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.

expiresAtDate

When 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
POST/media/uploadAssets
const { data, error } = await client.media.uploadAssets({
  body: {
    files: [{ name: 'hero.jpg', size: buffer.byteLength, type: 'image/jpeg', buffer }], // required
  },
});
Parameters
files{ name: string; size: number; type: string; buffer: Blob | ArrayBuffer; variantOf?: string }[]required

Files with byte content (at least one). The real byte length is measured server-side; the declared `size` is not trusted.

folderIdstring

Target folder id.

Returns
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
POST/media/replaceAsset
const { data, error } = await client.media.replaceAsset({
  body: {
    assetId: 'ast_123', // required
    file: { name: 'hero.jpg', size: buffer.byteLength, type: 'image/jpeg', buffer }, // required
  },
});
Parameters
assetIdstringrequired

The asset to replace.

file{ name: string; size: number; type: string; buffer: Blob | ArrayBuffer }required

The new file bytes. Sniffed like `uploadAssets`. Server-side only — see the callout above.

Returns
assetAssetListItem

The 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
POST/media/createSignedReplace
const { data, error } = await client.media.createSignedReplace({
  body: {
    assetId: 'ast_123', // required
    file: { name: 'hero-v2.jpg', size: file.size, type: file.type }, // required
  },
});
Parameters
assetIdstringrequired

The asset to replace.

file{ name: string; size: number; type: string }required

The new file's declared metadata — no bytes; the browser PUTs them straight to your bucket.

Returns
assetIdstring

Echoes the requested asset id — pass it through to `commitReplace`.

slugstring

The freshly minted slug (cache-bust). Pass it through to `commitReplace`.

objectKeystring

The freshly minted object key (equal to `slug`). Pass it through to `commitReplace`.

urlstring

The object's public URL once the PUT below succeeds AND `commitReplace` repoints the row. For internal/admin display only.

signedUrlstring

Presigned PUT URL — upload the new bytes here.

headersRecord<string, string>

Headers to send with the PUT (`Content-Type`, `x-amz-acl: public-read`).

expiresAtDate

When 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
POST/media/commitReplace
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
  },
});
Parameters
assetIdstringrequired

The asset being replaced (must match the `createSignedReplace` call).

objectKeystringrequired

The `objectKey` returned by `createSignedReplace`.

slugstringrequired

The `slug` returned by `createSignedReplace`.

mimeTypestringrequired

The uploaded file's declared MIME type.

sizenumberrequired

The uploaded file's declared byte size.

Returns
assetAssetListItem

The 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
POST/media/updateAssetsStatus
const { data, error } = await client.media.updateAssetsStatus({
  body: {
    assetIds: ['ast_123'], // required
    status: 'public', // required
  },
});
Parameters
assetIdsstring[]required

Asset ids to update (at least one).

status'private' | 'public'required

Target status.

Returns
updatednumber

How 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
POST/media/moveAssets
const { data, error } = await client.media.moveAssets({
  body: {
    assetIds: ['ast_123'], // required
    folderId: 'fld_images', // required; null moves to the root
  },
});
Parameters
assetIdsstring[]required

Asset ids to move (at least one).

folderIdstring | nullrequired

Target folder id, or `null` to move to the root.

Returns
movednumber

How 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
POST/media/archiveAssets
const { data, error } = await client.media.archiveAssets({
  body: { assetIds: ['ast_123'] }, // required
});
Parameters
assetIdsstring[]required

Asset ids to archive (at least one).

Returns
archivednumber

How 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.

Public
GET/media/asset/{assetId}
const { data, error } = await client.media.asset({
  params: { assetId: 'ast_123' }, // required
  query: { w: 800, format: 'webp' },
});
Parameters
assetIdstringrequired

Path parameter: the asset id to serve.

format'webp' | 'jpeg' | 'png'

Serve a pre-uploaded format variant, falling back to the original.

wnumber

Serve a pre-uploaded width variant (positive integer), falling back to the original.

downloadboolean

Set `true` to send a `Content-Disposition: attachment` header.

Returns
Location302 redirect

A `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.

On this page