Skip to main content

Profile Pictures

3 min read

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#