- A unique path per file.
uniquePathadds a random suffix, so two people attachingphoto.pngget 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.
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
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, neverlist(). 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
Cleanup
A user can close the tab halfway through an upload, and nothing tells your server. Because this route ismultipart: 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
Upload handler
uploadRoute, input, state, and multiple routes on one endpoint.Large files
Pause, resume, and what
multipart changes.Deleting
One path, a list, a prefix, and sweeping incomplete uploads.
Private documents
The same shape on a private bucket, with signed downloads.