A user clicks Export, a background job builds a CSV or a PDF, and a download link appears when it is ready. The file is private, and it stops working after a day.
Three choices make that work:
- 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.
This recipe uses a private bucket. Create it as private in the Upstash Console, then follow the Quickstart for the token and the SDK.
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.
The job has to run outside the request. A promise left running after the response is killed on a serverless platform, so the work goes through QStash, which calls the run route below and retries it if it fails. Any queue or workflow runner works the same way.
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".
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.
Five minutes is the life of the link, not the life of the export. The row's 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.
The anchor is an ordinary link to your own route, so it can sit in the page, in a list of past exports, or in an email, and the route still decides who gets a signed URL.
The cleanup cron#
Your table is the index, so the sweep is one indexed query and one batch delete. Never list() 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.
The rows are the only thing that knows the paths, and if the second step fails the next run finds the same rows and tries again: 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.