> ## Documentation Index
> Fetch the complete documentation index at: https://upstash-dx-2982-blob-docs.mintlify.site/llms.txt
> Use this file to discover all available pages before exploring further.

# Generated Exports

<Frame>
  <img className="block dark:hidden" src="https://mintcdn.com/upstash-dx-2982-blob-docs/_w0BxdIkoSvH_8at/img/blob/exports-light.gif?s=4e1a047e8dd0c3293453dea34f51220b" alt="Click Export, wait while the job builds the file, click Download CSV, then see the link expire a day later" width="900" height="570" data-path="img/blob/exports-light.gif" />

  <img className="hidden dark:block" src="https://mintcdn.com/upstash-dx-2982-blob-docs/_w0BxdIkoSvH_8at/img/blob/exports-dark.gif?s=8d9c6726d60cc84a95355b26fdfb438b" alt="Click Export, wait while the job builds the file, click Download CSV, then see the link expire a day later" width="900" height="570" data-path="img/blob/exports-dark.gif" />
</Frame>

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](/blob/overall/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.

```ts lib/exports.ts theme={"system"}
import "server-only"
import { Bucket } from "@upstash/blob"
import { Client } from "@upstash/qstash"
import { buildCsv } from "./csv"
import { db } from "./db"

const bucket = Bucket.fromEnv()
const qstash = new Client({ token: process.env.QSTASH_TOKEN! })

const ONE_DAY = 24 * 60 * 60 * 1000

export async function startExport(userId: string) {
  const id = crypto.randomUUID()

  await db.exports.insert({
    id,
    userId,
    status: "pending",
    path: null,
    expiresAt: new Date(Date.now() + ONE_DAY),
  })

  await qstash.publishJSON({
    url: `${process.env.APP_URL}/api/exports/run`,
    body: { id },
  })

  return id
}
```

```ts app/api/exports/route.ts theme={"system"}
import { getUser } from "@/lib/auth"
import { startExport } from "@/lib/exports"

export async function POST(request: Request) {
  const user = await getUser(request)
  if (!user) return new Response("Unauthorized", { status: 401 })

  return Response.json({ id: await startExport(user.id) })
}
```

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](/qstash/overall/getstarted), 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".

```ts lib/exports.ts theme={"system"}
export async function runExport(id: string) {
  const row = await db.exports.find({ id })
  if (!row || row.status === "ready") return

  const csv = await buildCsv(row.userId)
  const path = `exports/${row.userId}/${row.id}.csv`

  await bucket.put(path, csv, { contentType: "text/csv", cache: "no-store" })
  await db.exports.update(row.id, { status: "ready", path })
}
```

```ts app/api/exports/run/route.ts theme={"system"}
import { verifySignatureAppRouter } from "@upstash/qstash/nextjs"
import { runExport } from "@/lib/exports"

export const POST = verifySignatureAppRouter(async (request: Request) => {
  const { id } = await request.json()
  await runExport(id)
  return new Response("ok")
})
```

`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](/qstash/quickstarts/vercel-nextjs).

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](/qstash/features/callbacks#what-is-a-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.

```ts app/api/exports/[id]/download/route.ts theme={"system"}
import { Bucket } from "@upstash/blob"
import { getUser } from "@/lib/auth"
import { db } from "@/lib/db"

const bucket = Bucket.fromEnv()

export async function GET(request: Request, { params }: { params: Promise<{ id: string }> }) {
  const user = await getUser(request)
  if (!user) return new Response("Unauthorized", { status: 401 })

  const { id } = await params
  const row = await db.exports.find({ id, userId: user.id })
  if (!row?.path || row.status !== "ready") return new Response("Not found", { status: 404 })
  if (row.expiresAt < new Date()) return new Response("Export expired", { status: 410 })

  const { url } = await bucket.signedReadUrl(row.path, {
    expiresIn: "5m",
    downloadAs: `export-${row.id}.csv`,
  })
  return Response.redirect(url, 302)
}
```

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](/blob/bucket/reading).

***

## The page

The button posts, then polls the row until it says ready.

```ts app/api/exports/[id]/route.ts theme={"system"}
import { getUser } from "@/lib/auth"
import { db } from "@/lib/db"

export async function GET(request: Request, { params }: { params: Promise<{ id: string }> }) {
  const user = await getUser(request)
  if (!user) return new Response("Unauthorized", { status: 401 })

  const { id } = await params
  const row = await db.exports.find({ id, userId: user.id })
  return row ? Response.json({ status: row.status }) : new Response("Not found", { status: 404 })
}
```

```tsx components/export-button.tsx theme={"system"}
"use client"

import { useEffect, useState } from "react"

export function ExportButton() {
  const [row, setRow] = useState<{ id: string; status: string }>()

  async function start() {
    const { id } = await fetch("/api/exports", { method: "POST" }).then((r) => r.json())
    setRow({ id, status: "pending" })
  }

  useEffect(() => {
    if (row?.status !== "pending") return
    const timer = setInterval(async () => {
      const { status } = await fetch(`/api/exports/${row.id}`).then((r) => r.json())
      if (status !== "pending") setRow({ id: row.id, status })
    }, 2000)
    return () => clearInterval(timer)
  }, [row])

  return (
    <>
      <button onClick={start} disabled={row?.status === "pending"}>Export</button>
      {row?.status === "pending" && <p>Building your export...</p>}
      {row?.status === "ready" && <a href={`/api/exports/${row.id}/download`}>Download CSV</a>}
      {row?.status === "failed" && <p>Export failed. Try again.</p>}
    </>
  )
}
```

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.

```ts app/api/cron/expire-exports/route.ts theme={"system"}
import { Bucket } from "@upstash/blob"
import { db } from "@/lib/db"

const bucket = Bucket.fromEnv()

export async function GET(request: Request) {
  if (request.headers.get("authorization") !== `Bearer ${process.env.CRON_SECRET}`) {
    return new Response("Unauthorized", { status: 401 })
  }

  const rows = await db.exports.findExpired({ before: new Date(), limit: 500 })
  const paths = rows.flatMap((row) => (row.path ? [row.path] : []))

  await bucket.del(paths)
  await db.exports.deleteMany(rows.map((row) => row.id))

  return Response.json({ expired: rows.length })
}
```

```json vercel.json theme={"system"}
{ "crons": [{ "path": "/api/cron/expire-exports", "schedule": "0 * * * *" }] }
```

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](/blob/bucket/deleting). Vercel sends `CRON_SECRET` on the requests it schedules, which is what keeps the route from being run by anyone else.

***

## Next steps

<CardGroup cols={2}>
  <Card title="Reading" href="/blob/bucket/reading">
    `signedReadUrl` options, `expiresAt`, and private buckets.
  </Card>

  <Card title="How signing works" href="/blob/reference/signing">
    What a signed URL can and cannot do, and how long it lives.
  </Card>

  <Card title="Deleting" href="/blob/bucket/deleting">
    One path, an array, a prefix, and what a partial delete reports.
  </Card>

  <Card title="Private documents" href="/blob/recipes/private-documents">
    Invoices and contracts, kept rather than expired.
  </Card>
</CardGroup>
