Skip to main content
One picture per user. A new upload replaces the old one, and every page shows the new picture right away, even though the image is cached for a year. Three choices make that work:
  • A stable path. avatars/${user.id} is overwritten on every upload, so there is never an old picture to clean up.
  • A versioned URL. versionedUrl carries the object’s etag on the query, so new bytes are a new URL and cache: "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 bytes go straight from the browser to storage, and your server only authorizes the upload. This recipe uses a public bucket. If you have not created one yet, start with the Quickstart.

The handler

lib/uploads.ts
The route takes images only, up to 5 MB, and refuses anything else before a byte is uploaded. The path has no extension on purpose: the object is stored and served as the type the browser declared. That is the whole update story. The path is the user id, so every upload overwrites the same object, and 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.