Content modelling
Uploads & media
Upload-enabled collections, the multipart flow, serving files, and upload fields.
1. Mark a collection as upload-enabled
const media = defineCollection({
slug: 'media',
upload: true,
fields: {
filename: defineField.text({ required: true }),
url: defineField.text(),
contentType: defineField.text(),
filesize: defineField.number(),
alt: defineField.text()
}
});
upload: true makes one thing true: POST /api/v1/media also accepts multipart/form-data. The
JSON path is unchanged, and every other collection is unaffected.
2. Post a file
curl -X POST http://localhost:5173/api/v1/media \
-H "Authorization: Bearer $TOKEN" \
-F 'file=@./hero.jpg' \
-F 'alt=Treatment room'
The part must be named file — anything else is a 400. What happens next:
- the bytes are stored through the
StorageAdapterunder<collection>/<uuid>-<filename>, and that key is recorded on the document as_storageKey. Only this pipeline can set it: no JSON body, form field, hook or Local API call can choose or change it; getPublicUrl(key)produces the URL;- a normal document is created, carrying whichever of
filename,url,contentTypeandfilesizeyour collection actually declares — fields you did not declare are dropped rather than inserted against a column that does not exist; - any other string form field that matches a declared field (
altabove) is written too.
So the media document is not special: it is validated, hooked, access-checked and listed like anything else.
3. Serve the bytes
Storing a file does not make it reachable. Mount handleFile on the path your storage adapter's
public URL points at:
// apps/<app>/src/server/routes/api/media/[...key].get.ts
import { defineEventHandler, getRouterParam, toWebRequest } from 'h3';
import { handleFile } from '@forge-cms/runtime';
import { getServerRuntime } from '../../../api/runtime';
export default defineEventHandler(async (event) => {
const runtime = await getServerRuntime(event.context.cloudflare?.env);
return handleFile(
{ request: toWebRequest(event), params: { key: getRouterParam(event, 'key') ?? '' } },
{ runtime }
);
});
Without this, every uploaded image on a deployment with a private bucket or the in-memory adapter is a broken link.
handleFile applies your collection's read access. It serves a key only as the file of the
upload document that owns it, meaning the document whose _storageKey is that key:
- Who can read. The caller (resolved from the session cookie or token, like any API request) must be able to read that document: collection and row access, and draft visibility.
- Not found. A key no document owns, or one whose document the caller cannot read, is the same
404. - Caching. Anonymous hits get
cacheControl(defaultpublic, max-age=60). Authenticated hits are alwaysprivate, no-store, so a shared cache never hands one user's file to another. - Older uploads. Uploads recorded before
_storageKeyexisted have no owner and return404here. Backfill their_storageKey(the part ofurlafter your media route) to serve them again.
If your bucket is public (an R2 custom domain, a CDN), point the adapter at it instead and skip the route:
new R2StorageAdapter({ publicUrlBase: 'https://cdn.example.com' });
Portable files: libSQL + S3
Off Cloudflare, the same pipeline runs on an on-disk libSQL database and an S3-compatible bucket
(@forge-cms/s3, server-side only). Nothing else changes: the same multipart handleCreate, the same
handleFile route, the same storage intents and reconcileStorage().
import { S3StorageAdapter } from '@forge-cms/s3';
const storage = new S3StorageAdapter({
bucket: process.env.S3_BUCKET!,
region: process.env.S3_REGION!,
endpoint: process.env.S3_ENDPOINT, // omit for AWS S3
forcePathStyle: true // most S3-compatible services
// credentials: omit to use the AWS SDK's provider chain
});
Keep the default public URL base (/api/media) so every read passes through handleFile's access check.
This combination is proven end to end against a real Garage service: upload, anonymous and protected
reads, restart persistence, delete, and recovery of a failed object delete or a failed database commit
through reconcileStorage(). A missing object is a 404; an unreachable bucket or rejected credentials
are a generic 500 that names no bucket, endpoint or key. There is still no transaction across the
database and the bucket. Only Garage is certified; AWS S3, Backblaze B2 and Wasabi are configuration
examples. Complete setups for both durable profiles (Cloudflare D1 + R2, and libSQL + S3) and the backup/restore
runbook are in Deployment; the CI journeys upload a file, serve it through handleFile, restart
the server and delete it on each profile. A direct bucket or CDN publicUrlBase bypasses handleFile and its access
checks — use it only for media that is public by design.
When storage and the database disagree
A database transaction cannot include your bucket. So Forge writes a storage intent row, in the database, whenever an object could end up owned by no document:
- Upload. The intent is written before the object is stored, and removed in the same batch that creates the document.
- Delete. The intent is written in the same batch that deletes the document, and removed once the object is gone.
A crash, or a failed cleanup at either step, leaves the intent behind instead of an unrecorded orphan. Work them off with:
const report = await runtime.reconcileStorage(); // { deleted, kept, pending, failed }
- Where to run it. From a scheduled job (a Cloudflare Cron Trigger) or an operator script. It is safe to run repeatedly and from several places at once.
- Grace period. Upload intents younger than
uploadGraceMs(default one hour) are left alone, because their upload may still be committing. - What it never does. It never deletes an object a document owns. A failed delete keeps its intent for the next run.
- Residual. A process that dies in the middle of reconciliation can leak that one object; it cannot break a document.
4. Reference the file from other collections
const posts = defineCollection({
slug: 'posts',
fields: {
title: defineField.text({ required: true }),
cover: defineField.upload({ collection: 'media' })
}
});
The stored value is the media document's id. Ask for depth: 1 and it comes back populated:
const { docs } = await runtime.find({ collection: 'posts', depth: 1 });
// docs[0].cover → { id, filename, url, contentType, filesize, alt, … }
curl "http://localhost:5173/api/v1/posts?depth=1"
The admin renders an upload field as a picker with a preview, an upload button and the existing
media library.
From the Angular client
// `fields` are extra string form fields; the content type is left to the browser so it can set
// the multipart boundary itself.
const doc = await cms.uploadFile('media', file, { alt: 'Treatment room' });
What is missing
Know these before building on it:
- Deleting a document deletes exactly the object in its
_storageKey, after the database delete succeeds. If that fails, a storage intent records it forreconcileStorage(). A document without one (created from JSON, or recorded before storage keys existed) deletes no object, and Forge logs a warning. Before spec 063, Forge guessed the key fromurl.urlis an editable field, so that guess could point at somebody else's file, and it was removed. Clean up objects from such older records by hand. - No Local API upload. Server code that must attach an object already in storage writes the row
through
runtime.adapters.database(the raw layer, outside every CMS check). - No image resizing, thumbnails or variants. What you upload is what you serve.
- No presigned/direct-to-storage uploads — bytes go through your server, which matters for large files on a Worker.
- Upload references restrict. Deleting a media document that an
uploadfield still references is refused (spec 064).