Media and images

Upload, pick, and serve images straight from your own R2 bucket

On a fresh deploy an editor can upload a picture, pick it in a field or block, caption it, and publish — no plugin to install and no paid Cloudflare product to sign up for. Originals live in your own R2 bucket. Turn on Image Transformations (one toggle, once you are on a custom domain) and the same pages serve resized, format-negotiated variants from the edge instead of the originals.

The media library

The media library at /admin/media is the single place every uploaded file lives. It sits in the Content section of the sidebar — editors think of media as content, not configuration. You can search by filename or alt text, filter by type, uploader, and usage, and switch between a grid and a list view.

Uploading is drag-and-drop: drop files anywhere on the page, or use the Upload files button to open the native picker. Each in-flight upload shows a progress card that settles into place on success or turns red with an inline error on rejection. Clicking any file opens a detail drawer where you edit the filename and alt text, copy the public URL, preview the asset at several sizes, and see exactly which documents use it before you delete anything. Uploads are stored byte-for-byte, so any embedded EXIF metadata — including GPS coordinates on photos taken with a phone — is preserved on the original; strip it before uploading if that matters for your site.

What gets accepted

Cusp validates every upload at the Worker boundary, not in the browser, so the rules cannot be bypassed by a crafted request.

  • Size — uploads are capped at 25 MB. Larger files are rejected before any bytes are stored.
  • Type — the allowlist is image-only: PNG, JPEG, WebP, GIF, and AVIF. The type is detected from the file's actual bytes, never trusted from the browser's Content-Type. SVG, HTML, and JavaScript are rejected even when a request claims they are an image, because an SVG can carry a script payload. Video and audio are rejected too.
  • Duplicates — uploading the same file twice resolves to a single stored copy and a single library entry. Paste the same logo across a dozen documents and you pay the storage cost once.

Cusp stores each image under a key derived from a hash of its bytes, so the stored object never changes once written and can carry an immutable cache header. This is also what makes duplicate detection automatic.

Media is a versioned entity. Every change — an alt edit, a rename, a delete — is recorded in the version history, so media participates in restore and site-wide time travel exactly like every other entity. Alt text is always present: an empty value is the deliberate signal for a decorative image, distinct from one that simply has not been described yet.

Image transformations

Cusp does not pre-generate variants when you upload. The original stays the single source of truth in R2, and the renderer emits Cloudflare Image Transformations URLs that the edge resolves and caches on first request. Pages ship with responsive widths of 400, 800, 1200, 1600, and 2400 pixels at quality 85, with automatic format negotiation to AVIF or WebP.

  • Free to start — Image Transformations is free for the first 5,000 unique transformations per calendar month. A typical published page costs only a handful, so a personal site never sees a bill. Beyond the free allowance, a Cloudflare Images Paid plan adds capacity at usage-based pricing.
  • Unsigned URLs — given the free allowance and the immutable cache headers on the underlying objects, signing transform URLs is not worth the cost. Cusp leaves them unsigned.
  • workers.dev fallback — Image Transformations is a zone-level feature and is off on the shared workers.dev domain. There a deploy serves the original bytes unoptimised; editors still see images and the site is never broken. Pointing a custom domain enables transforms.

Whether transforms are active is driven by a single setting, images.transform_enabled, and it is off by default — a fresh deploy serves original bytes. An administrator turns it on with the Image transformations toggle in Settings once the deployment is on a custom domain or zone with Cloudflare Image Transformations enabled. The default is off precisely so a brand-new deploy never composes a transform URL its zone cannot serve.

All transform URLs are built in one place — the transformedUrl helper. When the setting is off it returns the source URL unchanged, so the page always renders something.

src/shared/render/image-transforms.ts

The image block and media fields

Images reach a document two ways, and both open the same library. The image block lives in the block editor for inline pictures inside a document's body. A media field is a typed field on a content type — a recipe's hero image, a page's social-share image — declared in the schema and rendered as a picker on the document form.

A media field shows an empty dashed slot with Pick image and Upload new actions; once filled it shows a thumbnail, the filename, dimensions, and alt text, with Change and Clear. The picker shows the images you have uploaded so the editor can choose one from the grid. If the underlying file was deleted between save and load, the field shows a Source missing warning and prompts for a replacement rather than rendering a broken image.

Deleting and the media sweeper

Deleting a file is a soft-delete: the entry is tombstoned but the bytes stay in R2, so the file is recoverable from version history. Before you delete, the drawer lists every document that references the file, and removing a still-referenced file requires the administrator role.

The SweepMedia workflow runs daily at 02:00 UTC, scheduled with step.sleepUntil rather than a cron poll. It walks files past the 30-day retention window, scans every document — including any content spilled to R2 — for references, and only then permanently removes a file and its bytes, and only when nothing references it and no other live file shares the same content. That last check is why duplicate detection is safe: shared bytes are never reclaimed while any copy is still in use.

Next: Publishing and previews