# Reading

This page covers reading objects from your server: `get` for the bytes, `info` for the facts, `exists` for a boolean, `list` for a page of keys, and `signedReadUrl` for a link that reads a private object without going through your server.

```ts
import { bucket } from "@/lib/blob"

const res = await bucket.get("reports/2026-01.pdf")    // record plus body stream
const info = await bucket.info("reports/2026-01.pdf")  // record only
const ok = await bucket.exists("reports/2026-01.pdf")  // boolean
const page = await bucket.list({ prefix: "reports/" }) // one page of records
```

The records these return (`BlobObject`, `BlobInfo`, `BlobDownload`) are on [Types](/blob/reference/types#records).

---

## get

```ts
const res = await bucket.get("reports/2026-01.pdf")

res.contentType  // 'application/pdf'
res.size         // 184_302
res.etag         // '"9f3c..."'
res.metadata     // { owner: 'u7' }
res.body         // ReadableStream<Uint8Array>
```

`body` is a stream and nothing is buffered for you. Wrap it in a `Response` for the usual conversions:

```ts
const text = await new Response((await bucket.get("notes/1.md")).body).text()
const buffer = await new Response((await bucket.get("img/1.png")).body).arrayBuffer()
```

A missing object throws a `BlobError` with code `not_found`, status 404. See [Errors](/blob/reference/errors).

There is no range option. For byte ranges, use an [S3 client](/blob/bucket/connecting#using-an-s3-client).

---

## info

```ts
const info = await bucket.info("reports/2026-01.pdf")

info.size         // 184_302
info.etag         // '"9f3c..."'
info.contentType  // 'application/pdf'
info.metadata     // { owner: 'u7' }
info.uploadedAt   // Date
```

One HEAD request. The same record as `get` without the bytes, so reading a 2 GB object's facts is cheap. A missing object throws `not_found`.

`metadata` comes back from `get` and `info` only, with keys lowercased. See [Metadata](/blob/bucket/writing#metadata).

---

## exists

```ts
if (await bucket.exists("avatars/u7.png")) {
  // ...
}
```

The same HEAD request as `info`, answering `false` instead of throwing.

If you need the etag, size or metadata anyway, call `info()` and catch `not_found` instead of making two round trips.

---

## list

```ts
const page = await bucket.list({ prefix: "avatars/", limit: 100 })

page.blobs  // BlobObject[]
page.cursor // string | undefined, set only while more remains
```

All three are optional.

| Option | Type | Default | Description |
| --- | --- | --- | --- |
| `prefix` | `string` | none, the whole bucket | Only keys starting with this. |
| `limit` | `number` | storage picks, at most 1000 | Page size, clamped to 1 to 1000. |
| `cursor` | `string` | none, the first page | The `cursor` from the previous page. |

A full walk is a `do ... while`:

```ts
let cursor: string | undefined
const paths: string[] = []

do {
  const page = await bucket.list({ prefix: "avatars/", limit: 1000, cursor })
  for (const blob of page.blobs) paths.push(blob.path)
  cursor = page.cursor
} while (cursor)
```

Each entry is a `BlobObject`: path, size, etag, timestamp and URLs. There is no `contentType` or `metadata`; reading those is one `info()` per object.

`prefix` is the only filter. There is no query by owner, type or date, so "this user's files" has to be a prefix you chose at upload time. An app that needs to query its files should keep its own table and treat the bucket as storage, not an index.

---

## Public URLs

```ts
await bucket.publicUrl("avatars/u7.png")
// 'https://b0f3a91c24d.blob.upstash.io/avatars/u7.png'
```

Every record on a public bucket already carries `url`; `publicUrl` gives you one for any path. It returns `undefined` on a private bucket and throws a `TypeError` for an empty path or one with a `.` or `..` segment.

The URL itself is built from the token, but whether the bucket has a public host at all is known only to the backend, so the first call on a fresh client fetches credentials. They are cached, so every call after that is local.

### versionedUrl

```tsx
const avatar = await bucket.info(`avatars/${user.id}.png`)
<img src={avatar.versionedUrl} />  // https://.../avatars/u7.png?v=%229f3c...%22
```

`versionedUrl` is `${url}?v=${etag}`, so it changes whenever the content does. Use it for a stable path that gets overwritten: if `avatars/u7.png` is replaced every time the user picks a new picture, `url` never changes and caches keep serving the old bytes.

Pair it with `cache: 'immutable'` at upload. See [Caching](/blob/bucket/caching#immutable-plus-a-versioned-url).

---

## Private buckets

```ts
const bucket = Bucket.fromEnv()  // a bucket set to private in the console

const blob = await bucket.put("reports/2026-01.pdf", pdf)
blob.url          // undefined
blob.versionedUrl // undefined
await bucket.publicUrl("reports/2026-01.pdf")  // undefined
```

A private bucket has no public host, so `url` and `versionedUrl` are `undefined` on every record. Nothing in the code declares this: the SDK learns it from the backend when it fetches credentials, and objects are stored with `Cache-Control: private`. Reads go through `signedReadUrl()`.

---

## signedReadUrl

```ts
const { url, expiresAt } = await bucket.signedReadUrl("reports/2026-01.pdf", {
  expiresIn: "2m",
  downloadAs: "Report Q3.pdf",
})
```

A time-limited URL anyone can GET. Use it on a private bucket, or for an object you do not want linked from a public page.

All three are optional.

| Option | Type | Default | Description |
| --- | --- | --- | --- |
| `expiresIn` | `Duration` | `'5m'` | How long to ask for. `'15m'`, `'2h'`, or a bare number of seconds. |
| `downloadAs` | `string` | none, displayed inline | Save as this filename instead of displaying inline. |
| `contentType` | `string` | the stored type | What storage answers with as `Content-Type`, overriding what was stored. |

### Use `expiresAt`, not `expiresIn`

```ts
const cached = await cache.get(key)
if (!cached || cached.expiresAt < new Date()) {
  const link = await bucket.signedReadUrl(path, { expiresIn: "5m" })
  await cache.set(key, link)
}
```

`expiresIn` is what you asked for. `expiresAt` is what you got, and it can be sooner, because a link cannot outlive the credential that signed it. Cache the link until `expiresAt`, never until a deadline you compute yourself. This applies to `signedUploadUrl` too. The reason is on [How signing works](/blob/reference/signing#how-long-a-presigned-url-lives).

### downloadAs

```ts
await bucket.signedReadUrl(path, { downloadAs: "café ☕.pdf" })
```

Sets `Content-Disposition: attachment`, so the browser saves the file under that name rather than rendering it. Unicode names arrive intact. The filename is signed into the URL, so it cannot be edited afterwards.

### contentType

```ts
await bucket.signedReadUrl("exports/rows.bin", { contentType: "text/csv" })
```

Overrides what storage answers with, without rewriting the object. Throws `invalid_input` if it is not a valid media type.

---

## Next steps

<CardGroup cols={2}>
  <Card title="Writing" href="/blob/bucket/writing">
    `put`, metadata, conditional writes and multipart from the server.
  </Card>

  <Card title="Caching" href="/blob/bucket/caching">
    What `Cache-Control` an object is stored with, and pairing it with `versionedUrl`.
  </Card>

  <Card title="Deleting" href="/blob/bucket/deleting">
    One path, a list, a prefix, and sweeping incomplete uploads.
  </Card>

  <Card title="Errors" href="/blob/reference/errors">
    `BlobError`, the code list, and `BlobError.is`.
  </Card>
</CardGroup>
