- A stable path.
avatars/${user.id}is overwritten on every upload, so there is never an old picture to clean up. - A versioned URL.
versionedUrlcarries the object’s etag on the query, so new bytes are a new URL andcache: "immutable"is safe. - A URL on the user’s row. The bucket holds the bytes, your database is the index. Every page renders
user.avatarUrl.
The handler
lib/uploads.ts
versionedUrl ends in the new etag, so the row now points at a URL no browser or CDN has ever seen. The old cached picture is never requested again. Caching has the alternative, cache: "revalidate", for a URL that has to stay fixed.
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.
A refusal costs more on a stable path. The old picture was already overwritten, so a user whose save fails is left with no picture rather than the previous one.
The route
Mount the handler, then bind the hooks to it.app/api/upload/route.ts
lib/upload-hooks.ts
The picker
components/avatar-picker.tsx
accept comes from the route’s constraints, so the file dialog only offers images. upload.blob.data.avatarUrl is typed from what onUploadComplete returned, so the new picture is on screen the moment the upload finishes.
Showing the picture
Everywhere else, render the URL from your own row:Removing a picture
Clear the row first, then delete the object. The page stops showing the picture immediately, and if the second step fails the leftover is an object nobody links to rather than a broken image.del treats an already missing object as success, so this is safe to retry.
app/actions.ts
The same pattern fits any single image per row: a workspace logo, a product’s hero image, a cover photo. Name the path after the row’s id, and store
versionedUrl on the row. When one row owns many images, give each upload its own path instead, as in Product images.
Next steps
Caching
immutable, revalidate, and the versioned URL pattern in full.Constraints
What
image/* expands to, and why SVG is not in it.Upload handler
Everything the callbacks receive and return.
File attachments
Many files per thread, each with its own row.