Files that belong to the site rather than to a user: the images and brochures an editor uploads from an admin page, and the fonts, stylesheets and downloads your build ships. Nobody owns them, everybody reads them, and they should be cached for as long as possible.
Three choices make that work:
- 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.
This recipe uses a public bucket, since every one of these files is meant to be linked from a page. If you have not created one yet, start with the Quickstart. If you only need the deploy script, skip to Build-time assets.
The media handler#
Editors upload from the browser, so the bytes go straight to storage and your server only authorizes them.
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.
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.
The rendered page reads the row and never asks the bucket anything:
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.
A file read from disk is a buffer, which carries its length but not its type, so declare 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:
The trade-off between the two, and the 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 to app/actions.ts:
A build asset has no row, so there is only the object: await bucket.del('assets/css/app.v2.css'). del treats an already missing object as success, so both are safe to run again.