Skip to main content

File Attachments

3 min read

Files attached to a chat message, a comment, or a support ticket: many files per thread, uploaded by many people, kept for as long as the thread is.

Three choices make that work:

  • A unique path per file. uniquePath adds a random suffix, so two people attaching photo.png get two objects.
  • A row per attachment. Your table is the index. It answers "what is attached to this thread", and the bucket only holds the bytes.
  • A validated input. The browser sends the thread id, and your route checks it, and the user's membership, before anything is signed.

This recipe uses a public bucket. If you have not created one yet, start with the Quickstart.


The handler#

lib/uploads.ts

uploadRoute() is the route form that takes an input schema and a typed state. file.name is the original filename, and this callback is the only place it exists, so store it if you want to show it later.

multipart: true sends every file up in parts, which is what big files need, and nothing is stored at the path until the upload completes. See Large files.

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 picker#

components/attachment-input.tsx

The route declares a schema, so input is required here and a missing threadId does not compile. There is no accept on the input because the route takes any type. Three files upload at a time and the rest queue; concurrency on useUpload changes that.


Showing attachments#

Read your rows, never list(). The bucket cannot answer "what is attached to this thread", and your table already can.

components/attachment-list.tsx

url is the public URL, which is right for a public bucket. If an attachment must not be readable by anyone holding its URL, put it on a private bucket and sign each read instead.


Deleting#

Delete the row first, then the object. The thread stops listing the file immediately, and if the second step fails the leftover is an object nobody links to rather than a link that 404s. del treats an already missing object as success, so it is safe to retry.

app/actions.ts

Deleting a whole thread is the same thing in bulk. The rows are the only thing that knows the paths, so read them first, delete the objects, then drop the rows. If it fails partway, run it again:


Cleanup#

A user can close the tab halfway through an upload, and nothing tells your server. Because this route is multipart: true, that leaves unfinished parts rather than a stored file, and bucket.abortStaleMultipartUploads() on a daily cron clears them. Set its olderThan longer than your slowest upload, so a paused upload is not aborted underneath the user. Abandoned uploads has the cron, and what to do instead on a route that is not multipart.


Next steps#