- An editor-only upload route. The role check runs in
onBeforeUpload, before anything is signed. - A row per media file. Path, URL, alt text and uploader live in your table. The bucket holds bytes, your database is the index.
- A path that changes when the bytes do.
uniquePathfor uploads, a versioned filename for build assets, socache: 'immutable'is always honest.
The media handler
Editors upload from the browser, so the bytes go straight to storage and your server only authorizes them.lib/uploads.ts
uniquePath gives every upload its own object, so re-uploading a file called hero.png never replaces last month’s hero.png. Because the path never repeats, cache: 'immutable' needs no invalidation at all.
A throw out of onUploadComplete deletes the object, so the catch turns a database failure into a refusal the user can retry from. not_ready is the 503 code, the one that means try again. See Upload handler and Errors.
The route
Mount the handler, then bind the hooks to it.app/api/upload/route.ts
lib/upload-hooks.ts
The admin uploader
Alt text is not a property of the object, it is a property of the row, so it is filled in after the upload lands.components/media-uploader.tsx
app/actions.ts
Build-time assets
Fonts, compiled CSS and static downloads have no editor and no row. They are written by a script at deploy time, to paths you choose yourself, and the path is the identifier.scripts/upload-assets.ts
contentType yourself. Run the script from your deploy command, after the build and before the site goes live.
When one of them changes
cache: 'immutable' asks browsers and CDNs to keep the bytes for a year, so a changed file needs a path nothing has seen before: put the version in the filename, as app.v3.css, or use a content hash. The old object stays until you delete it, which is what makes a rollback free.
When a path genuinely has to stay stable, overwrite it and link versionedUrl instead of url. It is the same URL with the object’s etag on the query, so it changes whenever the bytes do. Wherever your templates get asset URLs from, a generated manifest or an env var, write versionedUrl there:
revalidate option for a URL you cannot version at all, is in Caching.
Removing an asset
For CMS media, delete the row first, then the object. The page stops linking the file immediately, and if the second step fails the leftover is an object nobody links to rather than a broken image. Add this toapp/actions.ts:
await bucket.del('assets/css/app.v2.css'). del treats an already missing object as success, so both are safe to run again.
Next steps
Writing
put, content types, and what bodies carry their own length.Caching
immutable, revalidate, and the versioned URL pattern in full.Constraints
What
image/* expands to, and why SVG is not in it.