- A private bucket. There is no public host, so the only way to read an export is a signed link your route hands out.
- A row per export job. It holds the owner, the status, the path and the deadline. The page polls it, the download route checks it, and the cron sweeps it.
- A short-lived signed URL. The link in the page points at a route of yours, and the signed URL is minted at click time.
Starting an export
The button hits a route that writes a pending row and hands the job to a queue. Nothing is stored yet: the row is what the browser gets back.lib/exports.ts
app/api/exports/route.ts
The job
Build the file,put it, then flip the row to ready. Flipping last is what makes a still-pending row mean “the job did not finish”.
lib/exports.ts
app/api/exports/run/route.ts
verifySignatureAppRouter refuses anything that did not come from QStash, so the route cannot be used to start jobs by hand. The ready check at the top makes a redelivered message a no-op. Setting up the QStash keys is in the QStash quickstart.
If buildCsv keeps throwing, QStash retries and then gives up, and the row stays pending for good. Give it a failed status so the button below can stop polling: set it from a QStash failure callback, or have the cleanup cron mark any row still pending after an hour.
put takes a string or a Buffer directly, which covers both a CSV you assembled and a PDF a renderer handed you. Neither carries a type of its own, so declare contentType or the object is stored as application/octet-stream. cache: 'no-store' is there because a link expiring does not take the bytes back out of the reader’s browser cache.
The download route
Check the owner, check the deadline, then sign. The link in the page points here, so it never expires and never leaks anything on its own.app/api/exports/[id]/download/route.ts
expiresAt says whether the export still exists, and the route checks it before signing. Sign for the whole remaining day only when the URL itself has to be mailed somewhere. A link that long works for anyone holding it, with no ownership check.
downloadAs sets the filename the browser saves. The rest of the options are in Reading.
The page
The button posts, then polls the row until it says ready.app/api/exports/[id]/route.ts
components/export-button.tsx
The cleanup cron
Your table is the index, so the sweep is one indexed query and one batch delete. Neverlist() the bucket for this. The order is the opposite of a user delete, objects first and rows second, because nothing links these rows and a failed run has to find them again.
app/api/cron/expire-exports/route.ts
vercel.json
del counts an already missing object as success, and an array is sent in batches of 1000. See Deleting. Vercel sends CRON_SECRET on the requests it schedules, which is what keeps the route from being run by anyone else.
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.
Deleting
One path, an array, a prefix, and what a partial delete reports.
Private documents
Invoices and contracts, kept rather than expired.