> ## 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.

# Private Documents

<Frame>
  <img className="block dark:hidden" src="https://mintcdn.com/upstash-dx-2982-blob-docs/_w0BxdIkoSvH_8at/img/blob/private-documents-light.gif?s=48538f757c8d5e5d5536d5a60d84ded5" alt="Click Upload PDF, watch the progress bar, then open an invoice through an ownership check that hands out a signed link expiring in two minutes" width="900" height="570" data-path="img/blob/private-documents-light.gif" />

  <img className="hidden dark:block" src="https://mintcdn.com/upstash-dx-2982-blob-docs/_w0BxdIkoSvH_8at/img/blob/private-documents-dark.gif?s=1739e4bc1f75e791554b0adbb6756ace" alt="Click Upload PDF, watch the progress bar, then open an invoice through an ownership check that hands out a signed link expiring in two minutes" width="900" height="570" data-path="img/blob/private-documents-dark.gif" />
</Frame>

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](/blob/overall/quickstart) for the token and the SDK.

***

## Files your server creates

A generated PDF is already on your server, so write it with `put`:

```ts lib/invoices.ts theme={"system"}
import "server-only"
import { Bucket } from "@upstash/blob"
import { db } from "./db"
import { renderInvoicePdf } from "./pdf"

const bucket = Bucket.fromEnv()

export async function storeInvoice(invoiceId: string) {
  const invoice = await db.invoices.find({ id: invoiceId })
  const pdf = await renderInvoicePdf(invoice)
  const path = `invoices/${invoice.id}.pdf`

  await bucket.put(path, pdf, { contentType: "application/pdf", cache: "no-store" })
  await db.documents.upsert({
    id: `invoice-${invoice.id}`,
    ownerId: invoice.customerId,
    name: `Invoice ${invoice.number}.pdf`,
    path,
  })
}
```

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.

```ts lib/uploads.ts theme={"system"}
import "server-only"
import { BlobError, uniquePath, uploadHandler } from "@upstash/blob"
import { getUser } from "./auth"
import { db } from "./db"

export const uploads = uploadHandler({
  constraints: { contentTypes: ["application/pdf"], maxSize: "50mb" },

  onBeforeUpload: async ({ request, file }) => {
    const user = await getUser(request)
    if (!user) throw new BlobError("unauthorized")

    return {
      path: uniquePath`documents/${user.id}/${file.name}`,
      cache: "no-store",
      metadata: { owner: user.id },
    }
  },

  onUploadComplete: async ({ uploadId, metadata, path, size, file }) => {
    try {
      // The browser retries this request on a flaky network, so upsert on uploadId.
      await db.documents.upsert({
        id: uploadId,
        ownerId: metadata.owner,
        name: file.name,
        path,
        size,
      })
    } catch (e) {
      // A throw here deletes the object, so make it a deliberate refusal the user can retry from.
      console.error("[uploads] could not record", path, e)
      throw new BlobError("not_ready", { message: "could not save the document, try again" })
    }
    return { documentId: uploadId }
  },
})
```

`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](/blob/uploads/upload-handler#onuploadcomplete) and [Errors](/blob/reference/errors#the-codes).

***

## The route

Mount the handler, then bind the hooks to it.

```ts app/api/upload/route.ts theme={"system"}
import { uploads } from "@/lib/uploads"

export const { GET, POST } = uploads
```

```ts lib/upload-hooks.ts theme={"system"}
"use client"

import { uploadHooks } from "@upstash/blob/react"
import type { uploads } from "./uploads"

export const { useUpload } = uploadHooks<typeof uploads>()
```

***

## 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.

```tsx components/document-picker.tsx theme={"system"}
"use client"

import { useUpload } from "@/lib/upload-hooks"

export function DocumentPicker() {
  const { start, upload, accept } = useUpload()

  return (
    <>
      <input type="file" accept={accept} onChange={(e) => start({ file: e.target.files?.[0] })} />
      {upload?.pending && <progress value={upload.percent} max={100} />}
      {upload?.status === "error" && <p>{upload.error.message}</p>}
      {upload?.status === "done" && <p>Uploaded {upload.file.name}</p>}
    </>
  )
}
```

***

## 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:

```ts app/api/documents/[id]/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 doc = await db.documents.find({ id, ownerId: user.id })
  if (!doc) return new Response("Not found", { status: 404 }) // not 403: do not confirm it exists

  const { url } = await bucket.signedReadUrl(doc.path, {
    expiresIn: "2m",
    downloadAs: doc.name,
  })
  return Response.redirect(url, 302)
}
```

The page reads rows, never the bucket, and links every document to that route:

```tsx components/document-list.tsx theme={"system"}
import { db } from "@/lib/db"

export async function DocumentList({ userId }: { userId: string }) {
  const docs = await db.documents.findMany({ ownerId: userId })

  return (
    <ul>
      {docs.map((doc) => (
        <li key={doc.id}>
          <a href={`/api/documents/${doc.id}`}>{doc.name}</a>
        </li>
      ))}
    </ul>
  )
}
```

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](/blob/bucket/reading), and what a holder of a signed URL can do with it is on [How signing works](/blob/reference/signing).

***

## 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.

```ts app/actions.ts theme={"system"}
"use server"

import { Bucket } from "@upstash/blob"
import { getUser } from "@/lib/auth"
import { db } from "@/lib/db"

const bucket = Bucket.fromEnv()

export async function deleteDocument(id: string) {
  const user = await getUser()
  const doc = await db.documents.find({ id, ownerId: user?.id })
  if (!doc) throw new Error("not found")

  await db.documents.delete(id)
  await bucket.del(doc.path)
}
```

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:

```ts theme={"system"}
const docs = await db.documents.findMany({ ownerId: userId })

await bucket.del(docs.map((doc) => doc.path))
await db.documents.deleteMany({ ownerId: userId })
```

***

## 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="Caching" href="/blob/bucket/caching">
    What `Cache-Control` a private object is stored with, and when to use `no-store`.
  </Card>

  <Card title="File attachments" href="/blob/recipes/attachments">
    Many files per thread, on a public bucket.
  </Card>
</CardGroup>
