Skip to main content
Invoices, contracts and records that only their owner may download, some generated by your server and some uploaded by the user, with an ownership check on every read. Three choices make that work:
  • 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.
This recipe uses a private bucket. Create it as private in the Upstash Console, then follow the Quickstart for the token and the SDK.

Files your server creates

A generated PDF is already on your server, so write it with put:
lib/invoices.ts
A stable path is right here: regenerating an invoice should replace the old one. On a private bucket 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
The page reads rows, never the bucket, and links every document to that route:
components/document-list.tsx
The link in the page never expires, because it points at your route. Keep 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
When a user closes their account, the rows are the only thing that knows the paths, since their documents sit under more than one prefix. Read them first, delete the objects, then drop the rows. If it fails partway, run it again:

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.