- A private bucket. There is no public host, so an object cannot be fetched by URL at all.
- A signed read URL per download. Your route checks who is asking, then hands out a link that expires in minutes.
- A row per document. Your table holds the path, the display name and the owner, and it is the only index. The bucket is never asked what a user owns.
Files your server creates
A generated PDF is already on your server, so write it withput:
lib/invoices.ts
blob.url is undefined, so the path is the only thing worth storing.
Files the user uploads
The same table, filled from a browser upload. The bytes go straight to storage, and the row is written when the upload completes.lib/uploads.ts
cache: "no-store" keeps a downloaded document out of the reader’s browser cache once the link it came from is dead.
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
The browser side of an upload is the same on a private bucket, with one difference: there is no public URL to render when it finishes, so the picker shows what your route returned instead.components/document-picker.tsx
The download route
Never put a signed URL in a page. Link to a route of your own, check ownership there, and sign at click time:app/api/documents/[id]/route.ts
components/document-list.tsx
expiresIn short, because the signed URL it hands out works for anyone who ends up holding it. downloadAs makes the browser save the file under its real name rather than the one in the path; leave it out to open the PDF inline. Link this route from emails too, never a signed URL.
To hand the URL to a client component instead of redirecting, return Response.json({ url, expiresAt }). expiresAt is the link’s real deadline, which can be sooner than the one you asked for. The rest of the options are on Reading, and what a holder of a signed URL can do with it is on How signing works.
Deleting
Delete the row first, then the object. The list stops showing the document 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
Next steps
Reading
signedReadUrl options, expiresAt, and private buckets.How signing works
What a signed URL can and cannot do, and how long it lives.
Caching
What
Cache-Control a private object is stored with, and when to use no-store.File attachments
Many files per thread, on a public bucket.