Upload and serve media
Configure S3 storage, upload assets from the browser, and serve them.
This guide uploads and serves assets. It assumes a CMS instance (see Quickstart) and an S3-compatible bucket. For the model behind it, see Media.
Configure storage
createCMS requires a media config: your bucket plus a public base URL (a CDN domain). The full shape is in the configuration reference:
media: {
provider: 'cloudflare',
accountId: process.env.R2_ACCOUNT_ID!,
bucketName: process.env.R2_BUCKET!,
accessKeyId: process.env.R2_KEY!,
secretAccessKey: process.env.R2_SECRET!,
publicUrl: process.env.R2_PUBLIC_URL!,
},Upload from the browser
The React client's media.useUploadAssets() returns an upload function plus progress state. Under the hood it requests signed URLs and PUTs each file straight to your bucket:
'use client';
import { cmsClient } from '@/lib/cms-client';
export function Uploader() {
const { upload, isUploading, totalProgress } = cmsClient.media.useUploadAssets();
return (
<>
<input
type="file"
multiple
onChange={(e) => upload(Array.from(e.target.files ?? []))}
/>
{isUploading ? <p>Uploading {totalProgress}%</p> : null}
</>
);
}upload(files, options?) takes a File[] and an optional { folderId }. Uploads are validated server-side against the limits (defaults: maxFiles 10, maxFileSize 4 MB, allowedMimeTypes ['image/*', 'video/*', 'application/pdf']); a rejected file surfaces as FILE_TOO_LARGE, TOO_MANY_FILES, or INVALID_FILE_TYPE. New assets are created private.
| Field | Type | Description |
|---|---|---|
upload | (files: File[], options?: { folderId?: string }) => Promise<void> | Start an upload. |
isUploading | boolean | Whether an upload is in progress. |
totalProgress | number | Overall progress, 0 to 100. |
files | MediaUploadFileState[] | Per-file name, progress, status, and result ({ id, slug, objectKey }). |
abort / reset | () => void | Cancel or clear. |
Optimize before upload
Add the media optimize plugin to resize and convert in the browser first. useOptimize returns OptimizeResult[] ({ file, originalVariant?, optimized }); flatten it to the File[] that upload expects:
const { results } = cmsClient.optimize.useOptimize(file);
const files = (results ?? []).flatMap((r) =>
r.originalVariant ? [r.file, r.originalVariant] : [r.file],
);
// cmsClient.media.useUploadAssets().upload(files)Serve an asset
Serve assets through the CMS gate: GET /media/asset/{id}, a public redirect to the object, addressed by the asset id (which is exactly what content stores). Use this in content and on public pages — it enforces status (a private asset returns ASSET_ACCESS_DENIED; flip one to public with updateAssetStatus) and honors ?format= and ?w= by resolving a pre-uploaded variant asset (falling back to the original). There is no on-the-fly transform: produce variants ahead of time (for example with the optimize plugin).
<img src="https://your-cms.example.com/media/asset/ast_6t4x5a98n2io352uhbkq" />listAssets and the upload responses also return a direct url per asset — your publicUrl joined with the asset's objectKey. It bypasses the gate (no status check, no transforms, ignores query strings), so it is for internal tooling like a media-library admin UI showing thumbnails of assets you manage — not for embedding in content:
const thumb = asset.url; // direct object URL — admin/library display only
// e.g. https://cdn.example.com/welcome-heroUpload from the server
For server-initiated uploads, call the API directly. createSignedUpload returns presigned PUT URLs (you then PUT the bytes with the returned headers); uploadAssets sends the bytes through the server (each file needs a buffer: Blob | ArrayBuffer):
const { assets } = await cms.api.media.createSignedUpload({
body: { files: [{ name: 'hero.jpg', size: file.size, type: 'image/jpeg' }] },
});
// PUT the file to assets[0].signedUrl using assets[0].headersFor the asset model, folders, and private vs public, see Media.