diff --git a/agent-resources/cli.mdx b/agent-resources/cli.mdx index 0358b998..6aeb7d36 100644 --- a/agent-resources/cli.mdx +++ b/agent-resources/cli.mdx @@ -91,6 +91,7 @@ upstash team # Teams and members upstash vector # Vector indexes upstash search # Search indexes upstash qstash # QStash instances +upstash blob # Blob buckets ``` Use `--help` on any command or subcommand for details: @@ -231,3 +232,20 @@ upstash qstash update-budget --qstash-id $QSTASH_ID --budget $BUDGET_DOLLARS # upstash qstash enable-prodpack --qstash-id $QSTASH_ID upstash qstash disable-prodpack --qstash-id $QSTASH_ID ``` + +# Blob + +```bash +upstash blob list +upstash blob get --bucket-id $BUCKET_ID +upstash blob get --bucket-id $BUCKET_ID --hide-credentials # omit token and token_next +upstash blob create --name $NAME --visibility $VISIBILITY # private (default), public +upstash blob create --name $NAME --cors $ORIGIN $ORIGIN # space-separated origins +upstash blob delete --bucket-id $BUCKET_ID --dry-run +upstash blob delete --bucket-id $BUCKET_ID +upstash blob credentials --bucket-id $BUCKET_ID # temporary S3 credentials +upstash blob credentials # from UPSTASH_BLOB_TOKEN +``` + +`blob credentials` exchanges a bucket token for temporary, bucket-scoped S3 credentials for use +with the AWS CLI, rclone, or any S3 SDK. `expiresAt` is the credential's expiry. diff --git a/blob/bucket/caching.mdx b/blob/bucket/caching.mdx new file mode 100644 index 00000000..9b0206d5 --- /dev/null +++ b/blob/bucket/caching.mdx @@ -0,0 +1,183 @@ +--- +title: "Caching" +--- + +This page covers the `cache` option: what `Cache-Control` an object is served with, where to set it, and which value to pick. + +```ts +await bucket.put("avatars/7.png", file, { contentType: "image/png", cache: "immutable" }) +``` + +`Cache-Control` is written once, at upload, and stored with the object. The CDN and the browser honor it on every read. There is no per-request override; changing it means writing the object again. + +--- + +## Which value to pick + +| Object | Path | `cache` | How readers see a change | +| --- | --- | --- | --- | +| Unique path per upload (`uniquePath`) | new each time | `'immutable'` | the path is new, nothing to invalidate | +| Stable path that changes rarely | stable | `'immutable'` plus `versionedUrl` | the URL changes with the etag | +| Fixed URL you do not control, or a client that drops query strings | stable | `'revalidate'` | a 304 check on every read | +| Private or sensitive | any | `'no-store'`, or a short duration | the link expires; see [below](#no-store-and-signed-reads) | + +--- + +## The `cache` option + +| Value | Stored header | +| ----- | ------------- | +| `'immutable'` | `public, max-age=31536000, immutable` | +| `'revalidate'` | `public, max-age=0, must-revalidate` | +| `'no-store'` | `no-store` | +| a duration (`'15m'`, `3600`) | `public, max-age=` | +| unset | `public, max-age=3600` | +| anything containing `=` or `,` | stored exactly as written | + +```ts +cache: "1h" // public, max-age=3600 +cache: 3600 // public, max-age=3600 +cache: "15 min" // public, max-age=900 +cache: "7d" // public, max-age=604800 +``` + +A duration is converted to whole seconds, so `'1500ms'` stores `max-age=1`. The grammar is on [Types](/blob/reference/types#duration). + +### The raw header + +```ts +cache: "public, max-age=60, s-maxage=31536000" +cache: "max-age=0, stale-while-revalidate=86400" +``` + +Anything containing `=` or `,` is treated as a raw header and stored as written. Use this for `s-maxage`, `stale-while-revalidate`, `no-transform` and anything else the three keywords do not cover. + +--- + +## `revalidate` versus a short max-age + +| | `cache: 'revalidate'` | `cache: '60s'` | +| --- | --- | --- | +| Unchanged object | 304, no body | full object, once a minute | +| Object just overwritten | next read sees it | up to 60 s of the old bytes | + +`'revalidate'` stores `public, max-age=0, must-revalidate`. The cached copy is checked with `If-None-Match` on every read, so an unchanged object costs a 304 with no body. A short max-age serves stale bytes until it expires, then re-downloads the whole object. + +`'revalidate'` costs a round trip per read, but is never stale and never downloads the bytes twice. + +--- + +## Where you can set it + +Four places. The most specific one wins. + +### On the bucket + +```ts lib/blob.ts +import { Bucket } from "@upstash/blob" + +export const bucket = Bucket.fromEnv({ cache: "immutable" }) +``` + +The default for every object this bucket stores. + +### On a put + +```ts +await bucket.put("avatars/7.png", file, { + contentType: "image/png", + cache: "revalidate", +}) +``` + +`updateJson` takes it too, for the object it rewrites. So do `copy` and `move`, for the destination. Without it the source's value carries over. + +### On a signed upload URL + +```ts +const upload = await bucket.signedUploadUrl("u/7/report.pdf", { + contentType: "application/pdf", + cache: "immutable", +}) + +await fetch(upload.url, { method: "PUT", headers: upload.headers, body }) +``` + +Signed into the URL and handed back in `headers`, so the uploader has to send it verbatim. + +### On a direct browser upload + +```ts lib/uploads.ts +import { uniquePath, uploadHandler } from "@upstash/blob" + +export const uploads = uploadHandler({ + onBeforeUpload: ({ file }) => ({ + path: uniquePath`uploads/${file.name}`, + cache: "immutable", + }), +}) +``` + +Decided per upload on your server and signed into the presigned PUT. See [Upload handler](/blob/uploads/upload-handler#onbeforeupload). + +--- + +## Private buckets + +| `cache` | Public bucket | Private bucket | +| ------- | ------------- | -------------- | +| unset | `public, max-age=3600` | `private, max-age=3600` | +| `'1m'` | `public, max-age=60` | `private, max-age=60` | +| `'immutable'` | `public, max-age=31536000, immutable` | `private, max-age=31536000, immutable` | +| `'revalidate'` | `public, max-age=0, must-revalidate` | `private, max-age=0, must-revalidate` | +| `'no-store'` | `no-store` | `no-store` | + +On a private bucket, `private` replaces `public`, so no shared cache keeps a copy of an object only a signed request may read. This follows the bucket's visibility in the console; nothing in the code declares it. + +A raw header string is passed through as written, visibility included: `cache: 'public, max-age=60'` on a private bucket stores `public, max-age=60`. + +--- + +## Immutable plus a versioned URL + +```ts app/api/avatar/route.ts +const blob = await bucket.put(`avatars/${user.id}.png`, file, { + contentType: "image/png", + cache: "immutable", +}) + +await db.users.update(user.id, { avatar: blob.versionedUrl }) +``` + +```tsx + +``` + +`versionedUrl` is `url` with the etag on the query, so it changes whenever the content does. A stable path stored `immutable` and served through `versionedUrl` is cached for a year, and every overwrite produces a URL no cache has seen. The path never moves, so nothing has to be deleted. + +`url` and `versionedUrl` are both `undefined` on a private bucket. + +--- + +## `no-store` and signed reads + +```ts +await bucket.put("private/report.pdf", body, { + contentType: "application/pdf", + cache: "no-store", +}) + +const { url, expiresAt } = await bucket.signedReadUrl("private/report.pdf") +``` + +These are two separate mechanisms. The link expires at `expiresAt`, but the stored `Cache-Control` outlives it: with a long max-age the reader's browser keeps the bytes after the link stops working. If a reader must not keep the bytes, store the object with `no-store`. + +`no-store` drops the visibility scope entirely and stores `no-store` on public and private buckets alike. + +See [signedReadUrl](/blob/bucket/reading#signedreadurl) for link lifetimes. + +--- + +## What the upload route itself caches + +An upload route's `GET` serves its constraints document with a 60 second `Cache-Control` of its own, unrelated to the objects the route stores. See [Constraints](/blob/uploads/constraints#in-the-browser). diff --git a/blob/bucket/connecting.mdx b/blob/bucket/connecting.mdx new file mode 100644 index 00000000..3e9c424f --- /dev/null +++ b/blob/bucket/connecting.mdx @@ -0,0 +1,112 @@ +--- +title: "Connecting" +--- + +This page covers creating the `Bucket` client, the options it takes, and running it on platforms without `process.env`. + +```ts lib/blob.ts +import { Bucket } from "@upstash/blob" + +export const bucket = Bucket.fromEnv() // reads UPSTASH_BLOB_TOKEN +``` + +Create a bucket in the [Upstash Console](https://console.upstash.com) and put its token in your environment: + +```bash .env +UPSTASH_BLOB_TOKEN=... +``` + +The token is a bearer secret. Anything holding it can read and write the whole bucket. Keep it server side, never in `NEXT_PUBLIC_`, `VITE_`, or any other variable your bundler inlines into client code. + +--- + +## Options + +```ts lib/blob.ts +import { Bucket } from "@upstash/blob" + +export const bucket = new Bucket({ + token: process.env.UPSTASH_BLOB_TOKEN!, + cache: "immutable", + enableTelemetry: false, +}) +``` + +`Bucket.fromEnv()` is the same constructor with `token` read from `UPSTASH_BLOB_TOKEN`. It takes the same options minus the token, and a variable name when the token lives somewhere else: + +```ts +Bucket.fromEnv({ cache: "immutable" }) +Bucket.fromEnv("REPORTS_BUCKET_TOKEN", { cache: "immutable" }) +``` + +| Option | Type | Default | Description | +| --- | --- | --- | --- | +| `token` | `string` | required | The bucket token. | +| `cache` | `CacheOption` | `'1h'` | The default `Cache-Control` for every object this client stores. A per-call `cache` overrides it. See [Caching](/blob/bucket/caching). | +| `enableTelemetry` | `boolean` | `true` | See [Telemetry](#telemetry). | + +Whether the bucket is public or private is a console setting, not an option: the SDK learns it from the backend on the first request. See [Private buckets](/blob/bucket/reading#private-buckets). + +Constructing a `Bucket` per request is fine. Credentials are cached per token, so two clients built from the same token share one. + +--- + +## Cloudflare Workers + +There is no `process.env` on Workers, so `Bucket.fromEnv()` throws. Pass the token from the request's `env`: + +```ts src/index.ts +import { Bucket } from "@upstash/blob" + +export default { + async fetch(request: Request, env: { UPSTASH_BLOB_TOKEN: string }) { + const bucket = new Bucket({ token: env.UPSTASH_BLOB_TOKEN }) + await bucket.put("hits.txt", "1") + return new Response("ok") + }, +} +``` + +An [upload handler](/blob/uploads/upload-handler#the-bucket) on Workers needs the same thing: build the bucket from `env` and pass it as `bucket:`. + +--- + +## Telemetry + +The SDK sends its version, runtime and platform as headers on credential requests to Upstash. Turn it off with `UPSTASH_DISABLE_TELEMETRY` in the environment (any value), or `enableTelemetry: false` on the `Bucket`. + +--- + +## Using an S3 client + +```ts +import { GetObjectCommand, S3Client } from "@aws-sdk/client-s3" +import { bucket } from "@/lib/blob" + +const { endpoint, region, bucket: name, credentials } = bucket.s3() +const s3 = new S3Client({ endpoint, region, credentials }) + +await s3.send( + new GetObjectCommand({ Bucket: name, Key: "reports/q3.pdf", Range: "bytes=0-1023" }), +) +``` + +Buckets are backed by Cloudflare R2 and are S3-compatible. `bucket.s3()` returns a config for `@aws-sdk/client-s3`, for anything the SDK does not wrap: byte ranges, conditional GETs, delimiters and common prefixes, object tagging. + +`endpoint` and `credentials` are async providers rather than values. Pass them through as they come, so the aws-sdk can pick up a fresh credential when the old one expires. + +An error message that mentions R2 is talking about the storage layer. + +--- + +## Next steps + + + + `put`, metadata, conditional writes and multipart from the server. + + + + `get`, `info`, `exists`, `list` and signed read URLs. + + diff --git a/blob/bucket/deleting.mdx b/blob/bucket/deleting.mdx new file mode 100644 index 00000000..dc74c494 --- /dev/null +++ b/blob/bucket/deleting.mdx @@ -0,0 +1,164 @@ +--- +title: "Deleting" +--- + +This page covers `del`, which deletes one path, a list of paths, or everything under a prefix, and the calls that clean up multipart uploads that were never completed. + +```ts +import { bucket } from "@/lib/blob" + +await bucket.del("avatars/me.png") // one path +await bucket.del(["a.png", "b.png", "c.png"]) // an array of paths +await bucket.del({ prefix: "tmp/" }) // everything under a prefix +``` + +All three shapes resolve to `Promise` and treat "already gone" as success. They differ in how many requests they make and what they throw when storage refuses part of the work. + +```ts +type DeleteTarget = string | string[] | { prefix: string; all?: boolean } +``` + +Anything else is refused with `invalid_input`. A path with a `.` or `..` segment throws a `TypeError`; see [Paths](/blob/bucket/writing#paths). + +The upload handler also deletes on its own: a throw out of `onUploadComplete`, or a `cancel()` from the browser, removes the object that upload wrote. See [onUploadComplete](/blob/uploads/upload-handler#onuploadcomplete). + +--- + +## One path + +```ts +await bucket.del("drafts/9f3c.txt") +await bucket.del("drafts/9f3c.txt") // still no throw +``` + +One `DELETE` request. A 404 counts as success, so a delete is safe to run from a retried job or an at-least-once queue consumer. Any other failure throws; see [Errors](/blob/reference/errors#what-storage-errors-map-to). + +`del` never says whether anything was there. To find out, call `bucket.exists(path)` first. + +--- + +## An array + +```ts +import { BlobError } from "@upstash/blob" + +try { + await bucket.del(paths) +} catch (e) { + if (BlobError.is(e) && e.code === "partial_delete") { + await requeue(e.failed ?? []) // the paths still in the bucket, verified one by one + return + } + throw e +} +``` + +Sent as batch deletes in chunks of 1000 paths, so a 5000-path array is five requests. A bad path fails the chunk it is in; earlier chunks have already run. + +When storage reports keys as failed, the SDK re-checks each one and keeps only the paths still there. If any survive, `del` throws `partial_delete` with them in `failed`. Everything not in `failed` was deleted. To recover, retry with `e.failed`. + +Use `BlobError.is(e)`, never `instanceof`. See [Errors](/blob/reference/errors). + +--- + +## A prefix + +```ts +await bucket.del({ prefix: "users/7/tmp/" }) +``` + +Pages through `list()` at 1000 objects per page and batch-deletes each page as it goes. A prefix with 100,000 objects is 100 list requests and 100 batch deletes, run one after another. It is not atomic; objects written under the prefix while it runs may or may not be caught. + +Failures work as for an array. Survivors from every page are collected and thrown as `partial_delete`. + + +`del({ prefix: '' })` would match every object in the bucket, so an empty prefix is refused with `invalid_input`. This protects against an unset variable or an empty form field. To wipe the bucket on purpose, say so: + +```ts +await bucket.del({ prefix: "", all: true }) +``` + +`all` is only consulted for the empty prefix. + + +--- + +## `move` leaves a copy on failure + +```ts +import { BlobError } from "@upstash/blob" + +try { + await bucket.move("tmp/9f3c", "avatars/7.png") +} catch (e) { + if (BlobError.is(e) && e.code === "move_left_a_copy") { + // avatars/7.png exists and is correct. tmp/9f3c is also still there. + await bucket.del("tmp/9f3c") // retry the source delete, not the move + return + } + throw e +} +``` + +`move` is a copy followed by a delete, because storage has no rename. If the copy fails, nothing changed and you get the copy's error. If the copy succeeds and the delete fails, `move` throws `move_left_a_copy` and **keeps the destination**, so you have two objects rather than none. The original error is on `cause`. + +--- + +## Incomplete multipart uploads + +A multipart upload becomes an object only when it is completed. Until then its parts are billed storage that `list()` cannot see, and the bucket cannot be deleted while one exists. A browser tab closed mid-upload leaves exactly this behind. + +`bucket.put()` and a browser `cancel()` abort their own uploads on failure. Anything else needs a sweep. The cron that runs it is on [Abandoned uploads](/blob/uploads/abandoned-uploads#sweeping-incomplete-multipart-uploads); the calls it uses are below. + +### listMultipartUploads + +```ts +const uploads = await bucket.listMultipartUploads({ prefix: "uploads/" }) +// [{ path: 'uploads/big.mp4', uploadId: 'ABC...', initiatedAt: Date }, ...] +``` + +Returns every upload started and neither completed nor aborted, paging internally until it has them all. `prefix` is optional. + +| Field | Meaning | +| --- | --- | +| `path` | The key the upload was started for. Nothing is stored there yet. | +| `uploadId` | Storage's id for the upload, needed to abort it. | +| `initiatedAt` | When it was started. What "stale" is measured against. | + +### abortMultipartUpload + +```ts +await bucket.abortMultipartUpload({ path: "uploads/big.mp4", uploadId: "ABC..." }) +``` + +Throws the upload away with every part that landed for it. An upload that is already gone counts as success. An empty `uploadId` is refused with `invalid_input`. + +`onUploadComplete` receives `multipartUploadId` for exactly this pair. Store it with your row and you can abort a specific upload later without listing the bucket. It is `undefined` for a single PUT. + +### abortStaleMultipartUploads + +```ts +const aborted = await bucket.abortStaleMultipartUploads({ + olderThan: "1d", + prefix: "uploads/", +}) +// [{ path, uploadId, initiatedAt }, ...] +``` + +List plus abort in one call, meant for a cron. `olderThan` is required, a [Duration](/blob/reference/types#duration). Only uploads started longer ago than that are touched, so a window longer than your slowest upload never aborts one still running. A day is a reasonable default. + + +An abandoned upload **under** the multipart threshold is not a multipart upload. It is an ordinary stored object that `list()` can see, and none of the calls above can find it. See [Abandoned uploads](/blob/uploads/abandoned-uploads). + + +--- + +## Error codes + +| Code | Raised by | +| --- | --- | +| `partial_delete` | An array or prefix delete where objects survived. `failed` lists them. | +| `move_left_a_copy` | `move`, when the source delete failed. | +| `invalid_input` | A bad `DeleteTarget`, an empty prefix without `all`, an empty `uploadId`. | + +`del` never raises `not_found`. Statuses and extra fields are on [Errors](/blob/reference/errors#the-codes). diff --git a/blob/bucket/reading.mdx b/blob/bucket/reading.mdx new file mode 100644 index 00000000..f23820fc --- /dev/null +++ b/blob/bucket/reading.mdx @@ -0,0 +1,219 @@ +--- +title: "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 +``` + +`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`) + // 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 + + + + `put`, metadata, conditional writes and multipart from the server. + + + + What `Cache-Control` an object is stored with, and pairing it with `versionedUrl`. + + + + One path, a list, a prefix, and sweeping incomplete uploads. + + + + `BlobError`, the code list, and `BlobError.is`. + + diff --git a/blob/bucket/writing.mdx b/blob/bucket/writing.mdx new file mode 100644 index 00000000..6b1e6d55 --- /dev/null +++ b/blob/bucket/writing.mdx @@ -0,0 +1,342 @@ +--- +title: "Writing" +--- + +This page covers every way to write an object from your server: `put` for bytes you have, `copy` and `move` to rearrange them, `updateJson` for a read-modify-write, and `signedUploadUrl` to hand one write to somebody else. + +```ts lib/reports.ts +import { bucket } from "@/lib/blob" + +export async function saveReport(pdf: Blob) { + const blob = await bucket.put("reports/q3.pdf", pdf, { contentType: "application/pdf" }) + return blob.url +} +``` + +`bucket` is the client from [Connecting](/blob/bucket/connecting). Everything here runs on your server with the bucket token. + +For files a user picks in the browser, do not proxy the bytes through your app. Use an [upload handler](/blob/uploads/upload-handler) instead. + +--- + +## put + +```ts +const blob = await bucket.put("reports/q3.pdf", pdf, { contentType: "application/pdf" }) +``` + +### Options + +Every option is optional. + +| Option | Type | Default | What it does | +| --- | --- | --- | --- | +| `contentType` | `string` | the body's type, else `application/octet-stream` | What the object is stored as. | +| `contentTypes` | `string[]` | none, any type | An allow list such as `["image/*", "application/pdf"]`. The declared type must be in it, and the body's leading bytes must not contradict the declared type. A refusal is `content_type_not_allowed` and nothing is written. | +| `maxSize` | `Size` | none | Refuses a body over this size with `too_large`. Also caps how much of an unknown-length stream is buffered. | +| `cache` | `CacheOption` | the bucket default | The `Cache-Control` this object is stored with. | +| `metadata` | `Record` | none | Custom key-value pairs stored with the object. Keys come back lowercased, values must be printable ASCII. | +| `size` | `number` | what the body carries | The exact length in bytes, for a stream whose size is not otherwise known. | +| `allowOverwrite` | `boolean` | `true` | `false` refuses the write if something is already at the path. The refusal is `already_exists`. Unlike Vercel Blob, the default overwrites, so `put` stays safe to retry. | +| `ifUnchanged` | `string` | none | An etag. The write fails with `conflict` if the object changed since you read it. | +| `multipart` | `boolean \| Size` | `'16mb'` | Bodies over this size go up in parts. `true` always, `false` never. | + +`Size` is a decimal byte count like `4096` or `'20mb'` ([Types](/blob/reference/types#size)). The content type grammar and wildcards are on [Constraints](/blob/uploads/constraints). + +### Return value + +```ts +const blob = await bucket.put("avatars/7.png", file, { contentType: "image/png" }) + +blob.path // 'avatars/7.png' +blob.url // https://b3f9a2c7d1e4.blob.upstash.io/avatars/7.png +blob.versionedUrl // ...?v=%22d41d8...%22 +blob.size // bytes stored +blob.etag // '"d41d8..."', what ifUnchanged takes +blob.uploadedAt // Date +blob.contentType // 'image/png' +``` + +This is a `CompletedBlob` ([Types](/blob/reference/types#records)). On a private bucket `url` and `versionedUrl` are `undefined`. + +### Bodies + +```ts app/api/avatar/route.ts +import { bucket } from "@/lib/blob" + +export async function POST(request: Request) { + // A Request carries its own length and type, so nothing has to be declared. + const blob = await bucket.put("avatars/me.png", request, { + contentTypes: ["image/*"], + maxSize: "5mb", + }) + return Response.json({ url: blob.url }) +} +``` + +`put` accepts these body types. If the body does not carry its own length or type, declare `size` or `contentType` yourself. + +| Body | Carries its length | Carries a content type | +| --- | --- | --- | +| `Request` | From its `content-length` header | From its `content-type` header | +| `Blob` / `File` | Yes | Yes, when `type` is set | +| `ArrayBuffer` | Yes | No | +| Typed array | Yes | No | +| `string` | Yes, once UTF-8 encoded | No | +| `ReadableStream` | No | No | + +The default content type is `application/octet-stream`. An explicit `contentType` wins over what the body carries. + +A `Request` with no body, or one that has already been read, throws `empty_body`. Anything else is a `TypeError`. + +--- + +## Streams and unknown lengths + +```ts +await bucket.put("export.csv", stream) +// BlobError: Length required (pass { size } or { maxSize } so the length is known +// before the first byte) -- code 'length_required', status 411 +``` + +Storage needs a content length before the first byte goes out, and a `ReadableStream` has none. Pass one of the two: + +```ts +// Buffer up to the cap. A stream that runs past it is cancelled with too_large. +await bucket.put("export.csv", stream, { maxSize: "10mb" }) + +// Stream straight through, nothing buffered. The declared size must be exact. +await bucket.put("export.csv", stream, { size: 5000 }) +``` + +A body that does not match `size` fails the request rather than being stored at the wrong length. A `Request` that arrived chunked has no `content-length` and counts as an unknown length too. + +When proxying bytes through a route, keep `maxSize` under the platform's own request body cap, since that refusal happens before your route runs. See [Platform body limits](/blob/reference/errors#platform-body-limits). + +--- + +## Paths + +```ts +await bucket.put("uploads/../secrets/key.pem", body) +// TypeError: path may not contain "." or ".." segments +``` + +A path is any non-empty string, with `/` as structure. It is percent-encoded for you, so spaces and unicode are fine. `.` and `..` segments are rejected, not normalized, by every method that takes a path. + +### uniquePath + +```ts +import { uniquePath } from "@upstash/blob" + +const path = uniquePath`${user.id}/${file.name}` +// 'u7/holiday-pic-3xK9mBqR.png' +``` + +`uniquePath` builds a safe path out of values you do not control, like a filename from a browser. Slashes in the template literal are structure. Every `${}` value is reduced to a single slugged filename, so it can never add a directory, and one random suffix goes on the finished path: + +```ts +uniquePath`chat/${"../admin/x.png"}` // 'chat/x-9fQ2mAe7.png' +uniquePath`a/${"b/c"}` // 'a/c-Kd3xR8wP' +uniquePath`${"Q3 Report (final).pdf"}` // 'q3-report-final-7hTbN2xY.pdf' +uniquePath`${"!!! ***"}` // 'file-Wm4pQ8dK' +``` + +The rules for each value: + +- Lowercased. Runs of anything that is not a letter or a number become `-`. +- Letters and digits from any script survive, so `café.pdf` keeps `café`. +- The stem is capped at 64 characters, the extension at 8. + +The assembled path then gets one random 8-character suffix, on its last segment, before the extension. + +Two uploads of `photo.png` never land on the same object. To overwrite on purpose, write the path yourself. + +--- + +## Metadata + +```ts +await bucket.put("invoices/7.pdf", pdf, { + contentType: "application/pdf", + metadata: { owner: "u7", invoiceId: "2026-0042" }, +}) + +const info = await bucket.info("invoices/7.pdf") +info.metadata // { owner: 'u7', invoiceid: '2026-0042' } +``` + +`metadata` is a flat `Record` stored as `x-amz-meta-*` headers. Three rules: + +- **Keys come back lowercased.** Write them lowercase to begin with. +- **Values must be printable ASCII.** Anything else is refused with `invalid_input`. Percent-encode other text and decode it on the way back. +- **It comes back from `info()` and `get()`, not `list()`.** Reading metadata for many objects is one `info()` call each. + +```ts +await bucket.put("a.txt", "x", { metadata: { note: "café" } }) +// BlobError: metadata.note has characters storage does not carry back unchanged +// (metadata is printable ASCII; percent-encode anything else with encodeURIComponent) +// -- code 'invalid_input', status 400 + +await bucket.put("a.txt", "x", { metadata: { note: encodeURIComponent("café") } }) +decodeURIComponent((await bucket.info("a.txt")).metadata.note!) // 'café' +``` + +--- + +## Conditional writes + +```ts +// Refuse if anything is already at the path. +await bucket.put("u/7/profile.json", body, { allowOverwrite: false }) +// throws already_exists, with e.etag and e.size of what is there + +// Refuse if the object changed since you read it. +const current = await bucket.info("u/7/profile.json") +await bucket.put("u/7/profile.json", next, { ifUnchanged: current.etag }) +// throws conflict if somebody else wrote first +``` + +Both are enforced by storage, so there is no race window. + +Both turn multipart off, so a conditional write of a large body goes up as one request. Combining either with `multipart: true` throws: + +```ts +await bucket.put("big.bin", data, { multipart: true, allowOverwrite: false }) +// BlobError: Multipart: allowOverwrite: false and ifUnchanged are single-PUT only -- code 'invalid_input' +``` + +--- + +## updateJson + +```ts +interface Settings { + theme: string +} + +await bucket.updateJson("u/7.json", (prev) => ({ + ...(prev ?? {}), + theme: "dark", +})) +``` + +`updateJson` runs in the SDK, not in storage. It reads the document, calls your function with the parsed value, and writes the result back with `ifUnchanged`. If somebody wrote in between, it pauses briefly, reads again and re-runs your function. After `maxAttempts` failed writes it throws `conflict`. + +- Your function gets `null` when there is nothing to read. An empty object reads as `null` too. +- It may be async. It runs on every attempt, so keep it a pure transform. +- The object is written as `application/json`. Existing metadata is carried over unless you pass your own. + +### Options + +Every option is optional. + +| Option | Type | Default | What it does | +| --- | --- | --- | --- | +| `maxAttempts` | `number` | `6` | How many read-transform-write rounds to try before throwing `conflict`. The pause between rounds is jittered and doubles each time, starting under 50 ms. | +| `cache` | `CacheOption` | the bucket default | The `Cache-Control` the rewritten object is stored with. | +| `metadata` | `Record` | the existing metadata | Replaces the metadata on the object. | + +```ts +await bucket.updateJson( + "u/7.json", + (prev) => ({ ...(prev ?? {}), theme: "dark" }), + { maxAttempts: 10, metadata: { owner: "u7" }, cache: "no-store" }, +) +``` + +--- + +## copy and move + +```ts +const archived = await bucket.copy("tmp/9f3c", "archive/2026/report.pdf") +const moved = await bucket.move("tmp/9f3c", "reports/q3.pdf", { contentType: "application/pdf" }) +``` + +`copy` runs inside storage, so the bytes never travel through your app. Storage has no rename, so `move` is a copy followed by a delete of the source. Both return the destination's record. A missing source throws `not_found`. + +An existing destination is overwritten. There is no `allowOverwrite` here because storage does not honor a precondition on a copy's destination. + +### Options + +Every option is optional, and `move` takes the same ones as `copy`. + +| Option | Type | Default | What it does | +| --- | --- | --- | --- | +| `contentType` | `string` | the source's | What the destination is stored as. | +| `cache` | `CacheOption` | the source's, else the bucket default | The `Cache-Control` the destination is stored with. | +| `metadata` | `Record` | the source's | Replaces the metadata outright. It is not merged with the source's. | + +With no options the destination is an exact copy. Passing any one of them makes storage rewrite all three, so the SDK reads the other two off the source first and sends them back unchanged. + +A move is not atomic. If the copy lands and the delete fails, `move` throws `move_left_a_copy` and keeps both objects. Retry the source delete to recover; see [Deleting](/blob/bucket/deleting#move-leaves-a-copy-on-failure). + +--- + +## Large bodies + +```ts +await bucket.put("video.mp4", data, { multipart: "100mb" }) +``` + +A body over the [multipart threshold](/blob/uploads/large-files#the-multipart-threshold) of 16 MB goes up in parts instead of one PUT. `multipart` changes the threshold: a size sets a new one, `true` always uses parts, `false` never does. + +- A single PUT cannot carry more than about 5 GiB. `multipart: false` on a body that big throws `too_large`. +- Parts are sent one at a time. Any failure aborts the whole upload before throwing, so nothing is left behind. +- A body that does not match a declared `size` throws `invalid_input`. + +--- + +## Signed upload URLs + +```ts +const upload = await bucket.signedUploadUrl("u/7/report.pdf", { + contentType: "application/pdf", + size: pdf.size, + expiresIn: "15m", +}) + +await fetch(upload.url, { method: "PUT", headers: upload.headers, body: pdf }) +``` + +`signedUploadUrl` returns `{ url, headers, expiresAt }`: a URL somebody else can PUT exactly one object to. Use it for a CLI, a build step, or a server-to-server job whose bytes you do not want to relay. + +Every option is optional. + +| Option | Type | Default | What it does | +| --- | --- | --- | --- | +| `expiresIn` | `Duration` | `'1h'` | How long the link should live. | +| `contentType` | `string` | `application/octet-stream` | The `Content-Type` the upload must send, and what the object is stored as. | +| `cache` | `CacheOption` | the bucket default | The `Cache-Control` the object is stored with. | +| `metadata` | `Record` | none | Written as `x-amz-meta-*`, under the same rules as `put`. | +| `size` | `number` | none, any length | Pins the body's exact length, so a URL handed out for one file cannot upload another size. | +| `allowOverwrite` | `boolean` | `true` | `false` refuses the upload if something is already at the path. | + +`headers` are signed into the URL and must be sent verbatim. Drop one, change one, or add one, and storage answers 403. That is what stops the uploader from changing `metadata`. + +`expiresAt` may be sooner than what you asked for. Cache the link until then rather than computing your own deadline; see [Use expiresAt](/blob/bucket/reading#use-expiresat-not-expiresin). + +For a browser upload, use the [upload handler](/blob/uploads/upload-handler) instead. A signed URL is one PUT: no multipart, no resume, and nothing tells your server it happened. + +--- + +## Next steps + + + + `get`, `info`, `exists` and paging through `list`. + + + + One path, a list, a prefix, and what a partial delete reports. + + + + What `cache` accepts and why it is written once, at upload. + + + + Client options, Cloudflare Workers, and using an S3 client directly. + + diff --git a/blob/overall/pricing.mdx b/blob/overall/pricing.mdx new file mode 100644 index 00000000..e5bd681a --- /dev/null +++ b/blob/overall/pricing.mdx @@ -0,0 +1,6 @@ +--- +title: Pricing & Limits +url: https://upstash.com/pricing/blob +--- + +Please check our [pricing page](https://upstash.com/pricing/blob) for the most up-to-date information on pricing and limits. diff --git a/blob/overall/quickstart.mdx b/blob/overall/quickstart.mdx new file mode 100644 index 00000000..c43f579f --- /dev/null +++ b/blob/overall/quickstart.mdx @@ -0,0 +1,177 @@ +--- +title: "Quickstart" +--- + +Upstash Blob is S3-compatible object storage. `@upstash/blob` has a `Bucket` client for your server, and an upload handler plus React hooks that upload from the browser straight to storage. This page builds a working file picker on Next.js App Router. Other frameworks work the same way; see [Other frameworks](/blob/uploads/upload-handler#other-frameworks). + +--- + +## Setup + + +```bash npm +npm install @upstash/blob +``` + +```bash pnpm +pnpm add @upstash/blob +``` + +```bash yarn +yarn add @upstash/blob +``` + +```bash bun +bun add @upstash/blob +``` + + +Create a bucket in the [Upstash Console](https://console.upstash.com) and put its token in your environment. + +```bash .env +UPSTASH_BLOB_TOKEN=... +``` + +The console asks whether the bucket is public or private: + +- **Public**: every object has a public URL. For avatars, product images, anything a page links to directly. +- **Private**: no public URL. Every read goes through a time-limited [signed URL](/blob/bucket/reading#signedreadurl). For user documents, invoices, anything that must not be guessable. + +--- + +## Upload from your server + +```ts lib/reports.ts +import { Bucket } from "@upstash/blob" + +const bucket = Bucket.fromEnv() + +export async function saveReport(file: Blob) { + const blob = await bucket.put("reports/2026-01.pdf", file, { + contentType: "application/pdf", + }) + return blob.url +} +``` + +Bytes already on your server go to the bucket with `put`. `blob.url` is the public object URL, `undefined` on a private bucket. See [Writing](/blob/bucket/writing). + +--- + +## Upload from the browser + +Files a user picks go straight from the browser to storage. Your server only authorizes the upload and records the result, so the bytes never pass through it. + + + + + +```ts lib/uploads.ts +import "server-only" +import { uploadHandler } from "@upstash/blob" + +export const uploads = uploadHandler({ + constraints: { + maxSize: "20mb", + contentTypes: ["image/*", "application/pdf"], + }, + + onBeforeUpload: ({ file }) => ({ path: `images/${file.name}` }), +}) +``` + +The handler runs on your server. It decides who may upload, where the object goes, and what happens once it lands. It never sees the bytes. + +This one accepts anyone. [Upload handler](/blob/uploads/upload-handler) adds the auth check and the completion callback. + + + + + +```ts app/api/upload/route.ts +import { uploads } from "@/lib/uploads" + +export const { GET, POST } = uploads +``` + +`POST` runs the upload and `GET` serves the route's constraints. The hooks look at `/api/upload` by default, so nothing else has to name a URL. + + + + + +```ts lib/upload-hooks.ts +"use client" + +import { uploadHooks } from "@upstash/blob/react" +import type { uploads } from "./uploads" + +export const { useUpload } = uploadHooks() +``` + +`uploadHooks()` reads the handler's type, so route names and completion data are checked at compile time. The `import type` is erased at build time and never pulls server code into the browser bundle. + + + + + +```tsx app/page.tsx +"use client" + +import { useUpload } from "@/lib/upload-hooks" + +export default function Page() { + const { start, upload, accept } = useUpload() + + return ( +
+ start({ file: e.target.files?.[0] })} + /> + + {upload &&

{upload.status}

} + {upload?.pending && } + {upload?.status === "done" && ( + {upload.blob.path} + )} + {upload?.status === "error" &&

{upload.error.message}

} +
+ ) +} +``` + +
+ +
+ +You get these without more code: + +- **Multipart for large files.** Past 16 MB the SDK switches to parts, with pause, resume and per-part retry. See [Large files](/blob/uploads/large-files). +- **Retries.** Failed parts back off and retry, and an expired signature is refreshed mid-upload. +- **A picker that matches the server.** `accept` comes from the route's own `GET`, so an oversized file is refused before any request goes out. See [Constraints](/blob/uploads/constraints). +- **Progress.** `percent`, `status` and `pending` read the same for one PUT or 200 parts. +- **Types end to end.** Whatever `onUploadComplete` returns is `upload.blob.data` on the client. + +--- + +## Next steps + + + + Auth, routes, and the completion callback in full. + + + + `useUpload`, the record it renders, and the non-React client. + + + + `put`, metadata, conditional writes and multipart from the server. + + + + Avatars, attachments and private documents, wired end to end. + + diff --git a/blob/recipes/ai-images.mdx b/blob/recipes/ai-images.mdx new file mode 100644 index 00000000..5e547e8d --- /dev/null +++ b/blob/recipes/ai-images.mdx @@ -0,0 +1,184 @@ +--- +title: "AI-Generated Images" +--- + + + Type a prompt, click Generate, watch the fox image land in the gallery with its prompt, then delete it + Type a prompt, click Generate, watch the fox image land in the gallery with its prompt, then delete it + + +An image that a model just produced, kept so the user can see it again tomorrow. Providers hand back a temporary URL or a response you can only read once, so the bytes have to land somewhere of your own before they expire. + +Three choices make that work: + +- **A server-side write.** The bytes are already on your server when the model answers, so they go up with `put`. There is no browser upload here. +- **A new path per generation.** `generations/${user.id}/${generationId}` is written once and never overwritten, so `cache: 'immutable'` is unconditionally safe. +- **A row per generation.** It holds the prompt, the path and the URL, and it is what the gallery reads. + +This recipe uses a public bucket. If you have not created one yet, start with the [Quickstart](/blob/overall/quickstart). A private gallery is covered at the end. + +--- + +## Storing a generation + +Read the model's response as a `Blob` and hand it to `put`. A `Blob` carries its own length and its own type, so nothing has to be declared. + +```ts lib/generations.ts +import "server-only" +import { Bucket } from "@upstash/blob" +import { db } from "./db" + +const bucket = Bucket.fromEnv() + +// Any provider works. Whatever returns a Response with image bytes fits here. +async function generateImage(prompt: string) { + return fetch("https://api.your-model-provider.com/v1/images", { + method: "POST", + headers: { authorization: `Bearer ${process.env.MODEL_API_KEY}` }, + body: JSON.stringify({ prompt }), + }) +} + +export async function createGeneration(userId: string, prompt: string) { + const res = await generateImage(prompt) + if (!res.ok) throw new Error("the model did not return an image") + + const generationId = crypto.randomUUID() + const path = `generations/${userId}/${generationId}` + + const blob = await bucket.put(path, await res.blob(), { cache: "immutable" }) + + return db.generations.create({ + id: generationId, + userId, + prompt, + path, + url: blob.url, + createdAt: new Date(), + }) +} +``` + +The path has no extension on purpose: the object is stored and served as whatever type the provider answered with, `image/png` or `image/webp` alike. If the response carries no `Content-Type`, pass `contentType` yourself, or the object is stored as `application/octet-stream` and a browser downloads it instead of rendering it. + +Plenty of providers answer with JSON pointing at a temporary URL instead. Fetch that URL and store what comes back the same way: + +```ts +const { imageUrl } = await res.json() +const image = await fetch(imageUrl) + +const blob = await bucket.put(path, await image.blob(), { cache: "immutable" }) +``` + +Both forms hold the image in memory for the length of the call, which is fine for an image. For a body too big for that, `put` takes the response stream with a `size`; see [Writing](/blob/bucket/writing#streams-and-unknown-lengths). + +--- + +## The route + +```ts app/api/generate/route.ts +import { getUser } from "@/lib/auth" +import { createGeneration } from "@/lib/generations" + +export async function POST(request: Request) { + const user = await getUser(request) + if (!user) return new Response("Unauthorized", { status: 401 }) + + const { prompt } = await request.json() + const generation = await createGeneration(user.id, prompt) + + return Response.json({ id: generation.id, url: generation.url }) +} +``` + +Return your own URL, never the provider's. The provider's link is the one that stops working in an hour. + +--- + +## The gallery + +The bucket cannot answer "what has this user generated". Your table can, so read rows and render the URL stored on each one: + +```tsx app/gallery/page.tsx +import { redirect } from "next/navigation" +import { getUser } from "@/lib/auth" +import { db } from "@/lib/db" + +export default async function GalleryPage() { + const user = await getUser() + if (!user) redirect("/login") + + const generations = await db.generations.findMany({ userId: user.id }) + + return ( +
    + {generations.map((g) => ( +
  • + {g.prompt} +

    {g.prompt}

    +
  • + ))} +
+ ) +} +``` + +Each object is stored `immutable` under a path that is never reused, so a browser or CDN that has seen one keeps it for a year and there is nothing to invalidate. + +If the gallery is not public, create the bucket as private instead. `blob.url` is `undefined` there, so the row keeps only the path, and a route of yours checks ownership and calls `signedReadUrl` at click time. That shape is in [Private documents](/blob/recipes/private-documents). + +--- + +## Deleting + +Delete the row first, then the object. The gallery stops showing the image immediately, and if the second step fails the leftover is an object nobody links to rather than a broken image. `del` treats an already missing object as success, so it is safe to retry. + +```ts app/actions.ts +"use server" + +import { Bucket } from "@upstash/blob" +import { getUser } from "@/lib/auth" +import { db } from "@/lib/db" + +const bucket = Bucket.fromEnv() + +export async function deleteGeneration(id: string) { + const user = await getUser() + const generation = await db.generations.find({ id, userId: user?.id }) + if (!generation) throw new Error("not found") + + await db.generations.delete(id) + await bucket.del(generation.path) +} +``` + +When a user deletes their account, the rows are the only thing that knows the paths, so read them first, delete the objects, then drop the rows. If it fails partway, run it again: + +```ts +const generations = await db.generations.findMany({ userId }) + +await bucket.del(generations.map((g) => g.path)) +await db.generations.deleteMany({ userId }) +``` + +--- + +## Next steps + + + + Which bodies `put` accepts, and what a stream needs. + + + + `immutable`, `revalidate`, and what each one stores. + + + + One path, an array, and what a partial delete reports. + + + + The same shape on a private bucket, with signed reads. + + diff --git a/blob/recipes/attachments.mdx b/blob/recipes/attachments.mdx new file mode 100644 index 00000000..632ca0ef --- /dev/null +++ b/blob/recipes/attachments.mdx @@ -0,0 +1,218 @@ +--- +title: "File Attachments" +--- + + + Click Attach, pick two files, watch each row upload, post the reply with both attached, then remove one + Click Attach, pick two files, watch each row upload, post the reply with both attached, then remove one + + +Files attached to a chat message, a comment, or a support ticket: many files per thread, uploaded by many people, kept for as long as the thread is. + +Three choices make that work: + +- **A unique path per file.** `uniquePath` adds a random suffix, so two people attaching `photo.png` get two objects. +- **A row per attachment.** Your table is the index. It answers "what is attached to this thread", and the bucket only holds the bytes. +- **A validated `input`.** The browser sends the thread id, and your route checks it, and the user's membership, before anything is signed. + +This recipe uses a public bucket. If you have not created one yet, start with the [Quickstart](/blob/overall/quickstart). + +--- + +## The handler + +```ts lib/uploads.ts +import "server-only" +import * as z from "zod" +import { BlobError, uniquePath, uploadHandler, uploadRoute } from "@upstash/blob" +import { getUser } from "./auth" +import { db } from "./db" + +const attachment = uploadRoute()({ + constraints: { maxSize: "25mb" }, + multipart: true, + input: z.object({ threadId: z.string() }), + + onBeforeUpload: async ({ request, input, file }) => { + const user = await getUser(request) + if (!user) throw new BlobError("unauthorized") + + const member = await db.threadMembers.exists({ threadId: input.threadId, userId: user.id }) + if (!member) throw new BlobError("forbidden") + + return { + path: uniquePath`threads/${input.threadId}/${file.name}`, + state: { threadId: input.threadId, userId: user.id }, + } + }, + + onUploadComplete: async ({ uploadId, state, path, url, size, contentType, file }) => { + try { + // The browser retries this request on a flaky network, so upsert on uploadId. + await db.attachments.upsert({ + id: uploadId, + threadId: state.threadId, + userId: state.userId, + name: file.name, + path, url, size, contentType, + }) + } 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 attachment, try again" }) + } + return { attachmentId: uploadId } + }, +}) + +export const uploads = uploadHandler({ routes: { attachment } }) +``` + +`uploadRoute()` is the route form that takes an `input` schema and a typed `state`. `file.name` is the original filename, and this callback is the only place it exists, so store it if you want to show it later. + +`multipart: true` sends every file up in parts, which is what big files need, and nothing is stored at the path until the upload completes. See [Large files](/blob/uploads/large-files). + +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 +import { uploads } from "@/lib/uploads" + +export const { GET, POST } = uploads +``` + +```ts lib/upload-hooks.ts +"use client" + +import { uploadHooks } from "@upstash/blob/react" +import type { uploads } from "./uploads" + +export const { useUpload } = uploadHooks() +``` + +--- + +## The picker + +```tsx components/attachment-input.tsx +"use client" + +import { useUpload } from "@/lib/upload-hooks" + +export function AttachmentInput({ threadId }: { threadId: string }) { + const { start, uploads } = useUpload("attachment") + + return ( + <> + start({ files: e.target.files, input: { threadId } })} /> + +
    + {uploads.map((u) => ( +
  • + {u.file.name} + {u.pending && } + {u.status === "done" && attached} + {u.status === "error" && {u.error.message}} + {u.pending && } +
  • + ))} +
+ + ) +} +``` + +The route declares a schema, so `input` is required here and a missing `threadId` does not compile. There is no `accept` on the input because the route takes any type. Three files upload at a time and the rest queue; `concurrency` on [useUpload](/blob/uploads/upload-client#useupload) changes that. + +--- + +## Showing attachments + +Read your rows, never `list()`. The bucket cannot answer "what is attached to this thread", and your table already can. + +```tsx components/attachment-list.tsx +import { db } from "@/lib/db" + +export async function AttachmentList({ threadId }: { threadId: string }) { + const attachments = await db.attachments.findMany({ threadId }) + + return ( +
    + {attachments.map((a) => ( +
  • + {a.name} ({Math.round(a.size / 1024)} KB) +
  • + ))} +
+ ) +} +``` + +`url` is the public URL, which is right for a public bucket. If an attachment must not be readable by anyone holding its URL, put it on a private bucket and sign each read instead. + +--- + +## Deleting + +Delete the row first, then the object. The thread stops listing the file 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 +"use server" + +import { Bucket } from "@upstash/blob" +import { getUser } from "@/lib/auth" +import { db } from "@/lib/db" + +const bucket = Bucket.fromEnv() + +export async function deleteAttachment(id: string) { + const user = await getUser() + const attachment = await db.attachments.find({ id, userId: user?.id }) + if (!attachment) throw new Error("not found") + + await db.attachments.delete(id) + await bucket.del(attachment.path) +} +``` + +Deleting a whole thread is the same thing in bulk. The rows are the only thing that knows the paths, so read them first, delete the objects, then drop the rows. If it fails partway, run it again: + +```ts +const attachments = await db.attachments.findMany({ threadId }) + +await bucket.del(attachments.map((a) => a.path)) +await db.attachments.deleteMany({ threadId }) +``` + +--- + +## Cleanup + +A user can close the tab halfway through an upload, and nothing tells your server. Because this route is `multipart: true`, that leaves unfinished parts rather than a stored file, and `bucket.abortStaleMultipartUploads()` on a daily cron clears them. Set its `olderThan` longer than your slowest upload, so a paused upload is not aborted underneath the user. [Abandoned uploads](/blob/uploads/abandoned-uploads) has the cron, and what to do instead on a route that is not multipart. + +--- + +## Next steps + + + + `uploadRoute`, `input`, `state`, and multiple routes on one endpoint. + + + + Pause, resume, and what `multipart` changes. + + + + One path, a list, a prefix, and sweeping incomplete uploads. + + + + The same shape on a private bucket, with signed downloads. + + diff --git a/blob/recipes/avatars.mdx b/blob/recipes/avatars.mdx new file mode 100644 index 00000000..a718c6c8 --- /dev/null +++ b/blob/recipes/avatars.mdx @@ -0,0 +1,176 @@ +--- +title: "Profile Pictures" +--- + + + Click the avatar, pick a photo, watch it upload, then remove it + Click the avatar, pick a photo, watch it upload, then remove it + + +One picture per user. A new upload replaces the old one, and every page shows the new picture right away, even though the image is cached for a year. + +Three choices make that work: + +- **A stable path.** `avatars/${user.id}` is overwritten on every upload, so there is never an old picture to clean up. +- **A versioned URL.** `versionedUrl` carries the object's etag on the query, so new bytes are a new URL and `cache: "immutable"` is safe. +- **A URL on the user's row.** The bucket holds the bytes, your database is the index. Every page renders `user.avatarUrl`. + +The bytes go straight from the browser to storage, and your server only authorizes the upload. + +This recipe uses a public bucket. If you have not created one yet, start with the [Quickstart](/blob/overall/quickstart). + +--- + +## The handler + +```ts lib/uploads.ts +import "server-only" +import { BlobError, uploadHandler } from "@upstash/blob" +import { getUser } from "./auth" +import { db } from "./db" + +export const uploads = uploadHandler({ + constraints: { contentTypes: ["image/*"], maxSize: "5mb" }, + + onBeforeUpload: async ({ request }) => { + const user = await getUser(request) + if (!user) throw new BlobError("unauthorized") + + return { + path: `avatars/${user.id}`, + cache: "immutable", + metadata: { owner: user.id }, + } + }, + + onUploadComplete: async ({ metadata, path, versionedUrl }) => { + try { + await db.users.update({ id: metadata.owner, avatarUrl: versionedUrl }) + } 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 your picture, try again" }) + } + return { avatarUrl: versionedUrl } + }, +}) +``` + +The route takes images only, up to 5 MB, and refuses anything else before a byte is uploaded. The path has no extension on purpose: the object is stored and served as the type the browser declared. + +That is the whole update story. The path is the user id, so every upload overwrites the same object, and `versionedUrl` ends in the new etag, so the row now points at a URL no browser or CDN has ever seen. The old cached picture is never requested again. [Caching](/blob/bucket/caching) has the alternative, `cache: "revalidate"`, for a URL that has to stay fixed. + +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). + +**A refusal costs more on a stable path.** The old picture was already overwritten, so a user whose save fails is left with no picture rather than the previous one. + +--- + +## The route + +Mount the handler, then bind the hooks to it. + +```ts app/api/upload/route.ts +import { uploads } from "@/lib/uploads" + +export const { GET, POST } = uploads +``` + +```ts lib/upload-hooks.ts +"use client" + +import { uploadHooks } from "@upstash/blob/react" +import type { uploads } from "./uploads" + +export const { useUpload } = uploadHooks() +``` + +--- + +## The picker + +```tsx components/avatar-picker.tsx +"use client" + +import { useUpload } from "@/lib/upload-hooks" + +export function AvatarPicker({ src }: { src?: string }) { + const { start, upload, accept } = useUpload() + const current = upload?.status === "done" ? upload.blob.data.avatarUrl : src + + return ( + + ) +} +``` + +`accept` comes from the route's constraints, so the file dialog only offers images. `upload.blob.data.avatarUrl` is typed from what `onUploadComplete` returned, so the new picture is on screen the moment the upload finishes. + +--- + +## Showing the picture + +Everywhere else, render the URL from your own row: + +```tsx +{user.name} +``` + +--- + +## Removing a picture + +Clear the row first, then delete the object. The page stops showing the picture immediately, and if the second step fails the leftover is an object nobody links to rather than a broken image. `del` treats an already missing object as success, so this is safe to retry. + +```ts app/actions.ts +"use server" + +import { Bucket } from "@upstash/blob" +import { getUser } from "@/lib/auth" +import { db } from "@/lib/db" + +const bucket = Bucket.fromEnv() + +export async function removeAvatar() { + const user = await getUser() + if (!user) throw new Error("unauthorized") + + await db.users.update({ id: user.id, avatarUrl: null }) + await bucket.del(`avatars/${user.id}`) +} +``` + +--- + +The same pattern fits any single image per row: a workspace logo, a product's hero image, a cover photo. Name the path after the row's id, and store `versionedUrl` on the row. When one row owns many images, give each upload its own path instead, as in [Product images](/blob/recipes/product-images). + +--- + +## Next steps + + + + `immutable`, `revalidate`, and the versioned URL pattern in full. + + + + What `image/*` expands to, and why SVG is not in it. + + + + Everything the callbacks receive and return. + + + + Many files per thread, each with its own row. + + diff --git a/blob/recipes/exports.mdx b/blob/recipes/exports.mdx new file mode 100644 index 00000000..611aee39 --- /dev/null +++ b/blob/recipes/exports.mdx @@ -0,0 +1,250 @@ +--- +title: "Generated Exports" +--- + + + Click Export, wait while the job builds the file, click Download CSV, then see the link expire a day later + Click Export, wait while the job builds the file, click Download CSV, then see the link expire a day later + + +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 +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 +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 +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 +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 +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 +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 +"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 ( + <> + + {row?.status === "pending" &&

Building your export...

} + {row?.status === "ready" && Download CSV} + {row?.status === "failed" &&

Export failed. Try again.

} + + ) +} +``` + +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 +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 +{ "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 + + + + `signedReadUrl` options, `expiresAt`, and private buckets. + + + + What a signed URL can and cannot do, and how long it lives. + + + + One path, an array, a prefix, and what a partial delete reports. + + + + Invoices and contracts, kept rather than expired. + + diff --git a/blob/recipes/overview.mdx b/blob/recipes/overview.mdx new file mode 100644 index 00000000..ad166f8c --- /dev/null +++ b/blob/recipes/overview.mdx @@ -0,0 +1,26 @@ +--- +title: "Overview" +--- + +Each recipe is one feature wired end to end: the handler, the route, the component, the page that renders the result, and the delete. The choices are made and explained, so you can copy one, rename the paths, and have it work. + +Pick by the shape of the data: + +| Recipe | Bucket | Shape | +| --- | --- | --- | +| [Profile pictures](/blob/recipes/avatars) | public | one per user, overwritten in place, uploaded from the browser | +| [Product images](/blob/recipes/product-images) | public | many per product, ordered, uploaded from the browser | +| [Site assets and CMS media](/blob/recipes/site-assets) | public | one per file, from an editor's browser or a build script | +| [AI-generated images](/blob/recipes/ai-images) | public | one per generation, written by your server | +| [File attachments](/blob/recipes/attachments) | public | many per thread, multipart uploads from the browser | +| [Video uploads](/blob/recipes/video) | public | one per video, multi-gigabyte, multipart upload from the browser | +| [Private documents](/blob/recipes/private-documents) | private | one per document, generated by your server or uploaded | +| [Generated exports](/blob/recipes/exports) | private | one per export, built by a background job, expires after a day | + +Three rules run under every one of them: + +- **Your database is the index.** The bucket only holds the bytes, so every page reads a row rather than listing the bucket. +- **A path is unique per upload or overwritten on purpose, never both.** [Caching](/blob/bucket/caching) explains what each choice lets you cache. +- **Ids go in `state`, facts about the object in `metadata`.** `state` reaches only `onUploadComplete`, and it travels through the browser, so never put secrets in it. `metadata` is also written onto the object, for a later `bucket.info()` to read back. [Upload handler](/blob/uploads/upload-handler) has both. + +If you have not created a bucket yet, start with the [Quickstart](/blob/overall/quickstart). diff --git a/blob/recipes/private-documents.mdx b/blob/recipes/private-documents.mdx new file mode 100644 index 00000000..2e4de1c9 --- /dev/null +++ b/blob/recipes/private-documents.mdx @@ -0,0 +1,254 @@ +--- +title: "Private Documents" +--- + + + 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 + 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 + + +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 +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 +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 +import { uploads } from "@/lib/uploads" + +export const { GET, POST } = uploads +``` + +```ts lib/upload-hooks.ts +"use client" + +import { uploadHooks } from "@upstash/blob/react" +import type { uploads } from "./uploads" + +export const { useUpload } = uploadHooks() +``` + +--- + +## 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 +"use client" + +import { useUpload } from "@/lib/upload-hooks" + +export function DocumentPicker() { + const { start, upload, accept } = useUpload() + + return ( + <> + start({ file: e.target.files?.[0] })} /> + {upload?.pending && } + {upload?.status === "error" &&

{upload.error.message}

} + {upload?.status === "done" &&

Uploaded {upload.file.name}

} + + ) +} +``` + +--- + +## 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 +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 +import { db } from "@/lib/db" + +export async function DocumentList({ userId }: { userId: string }) { + const docs = await db.documents.findMany({ ownerId: userId }) + + return ( + + ) +} +``` + +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 +"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 +const docs = await db.documents.findMany({ ownerId: userId }) + +await bucket.del(docs.map((doc) => doc.path)) +await db.documents.deleteMany({ ownerId: userId }) +``` + +--- + +## Next steps + + + + `signedReadUrl` options, `expiresAt`, and private buckets. + + + + What a signed URL can and cannot do, and how long it lives. + + + + What `Cache-Control` a private object is stored with, and when to use `no-store`. + + + + Many files per thread, on a public bucket. + + diff --git a/blob/recipes/product-images.mdx b/blob/recipes/product-images.mdx new file mode 100644 index 00000000..7e7f7b63 --- /dev/null +++ b/blob/recipes/product-images.mdx @@ -0,0 +1,245 @@ +--- +title: "Product Images" +--- + + + Add three images in the admin panel, watch them upload in parallel and appear on the product page as each finishes, then remove one from both + Add three images in the admin panel, watch them upload in parallel and appear on the product page as each finishes, then remove one from both + + +Many images per product, uploaded by staff or sellers from an admin UI, shown in order on a public product page, and removed when the product is. + +Three choices make that work: + +- **A unique path per image.** `uniquePath` gives every upload its own object, so replacing an image is a new object at a new path and never an overwrite. +- **`cache: 'immutable'`.** Nothing is ever rewritten at a path, so every image can be cached for a year and a cached page can never show an image that has since changed. +- **A row per image.** `productId`, `path`, `url` and `sortOrder` live in your database. The bucket cannot answer "which images belong to this product", and it does not have to. + +The upload is the same shape as [File Attachments](/blob/recipes/attachments): a direct browser upload that your route authorizes, with the product id carried in `input`. + +This recipe uses a public bucket, since product pages link to the images directly. If you have not created one yet, start with the [Quickstart](/blob/overall/quickstart). + +--- + +## The handler + +The route checks that this user may edit this product, then names a fresh path for the image. + +```ts lib/uploads.ts +import "server-only" +import * as z from "zod" +import { BlobError, uniquePath, uploadHandler, uploadRoute } from "@upstash/blob" +import { getUser } from "./auth" +import { db } from "./db" + +const productImage = uploadRoute()({ + constraints: { contentTypes: ["image/*"], maxSize: "10mb" }, + input: z.object({ productId: z.string() }), + + onBeforeUpload: async ({ request, input, file }) => { + const user = await getUser(request) + if (!user) throw new BlobError("unauthorized") + + const canEdit = await db.products.canEdit({ productId: input.productId, userId: user.id }) + if (!canEdit) throw new BlobError("forbidden") + + return { + path: uniquePath`products/${input.productId}/${file.name}`, + cache: "immutable", + state: { productId: input.productId }, + } + }, + + onUploadComplete: async ({ uploadId, state, path, url }) => { + try { + // The browser retries this request on a flaky network, so upsert on uploadId. + await db.productImages.upsert({ + id: uploadId, + productId: state.productId, + path, + url, + sortOrder: Date.now(), // new images go last; a retry keeps its place + }) + } 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 image, try again" }) + } + + return { imageId: uploadId, url } + }, +}) + +export const uploads = uploadHandler({ routes: { productImage } }) +``` + +`cache: 'immutable'` is safe here only because the path is unique. Swapping an image out means uploading a new one and deleting the old row, never writing over the object a page is already linking to. The other two cache shapes are in [Caching](/blob/bucket/caching). + +`sortOrder` is a timestamp rather than a count, so three files uploading at once, or one completion request retried, cannot land on the same number. + +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 +import { uploads } from "@/lib/uploads" + +export const { GET, POST } = uploads +``` + +```ts lib/upload-hooks.ts +"use client" + +import { uploadHooks } from "@upstash/blob/react" +import type { uploads } from "./uploads" + +export const { useUpload } = uploadHooks() +``` + +--- + +## The admin uploader + +One picker, many files. `input` is required by the hook because the route declares a schema, so a missing `productId` fails to compile. + +```tsx components/product-image-uploader.tsx +"use client" + +import { useUpload } from "@/lib/upload-hooks" + +export function ProductImageUploader({ productId }: { productId: string }) { + const { start, uploads, accept } = useUpload("productImage") + + return ( + <> + start({ files: e.target.files, input: { productId } })} + /> + +
    + {uploads.map((u) => ( +
  • + {u.file.name} + {u.pending && } + {u.status === "done" && } + {u.status === "error" && {u.error.message}} +
  • + ))} +
+ + ) +} +``` + +`accept` comes from the route's constraints, so the file dialog only offers images. + +--- + +## The product page + +Read the rows, sorted by `sortOrder`, and render `url` straight from them. + +```tsx app/products/[id]/page.tsx +import { db } from "@/lib/db" + +export default async function ProductPage({ params }: { params: Promise<{ id: string }> }) { + const { id } = await params + const images = await db.productImages.findMany({ productId: id, orderBy: "sortOrder" }) + + return ( +
+ {images.map((image) => ( + + ))} +
+ ) +} +``` + +Reordering is a database update and nothing else. The path and the URL of an image never change, so dragging a thumbnail rewrites `sortOrder` on a few rows and uploads nothing. + +```ts app/actions.ts +"use server" + +import { getUser } from "@/lib/auth" +import { db } from "@/lib/db" + +export async function reorderImages(productId: string, imageIds: string[]) { + const user = await getUser() + if (!(await db.products.canEdit({ productId, userId: user?.id }))) throw new Error("forbidden") + + await db.productImages.setOrder({ productId, imageIds }) +} +``` + +--- + +## Deleting + +Delete the row first, then the object. The page stops showing the image immediately, and if the second step fails the leftover is an object nobody links to rather than a broken image. `del` treats an already missing object as success, so it is safe to retry. + +Add these to `app/actions.ts`: + +```ts +import { Bucket } from "@upstash/blob" + +const bucket = Bucket.fromEnv() + +export async function deleteProductImage(imageId: string) { + const user = await getUser() + const image = await db.productImages.find({ id: imageId }) + if (!image) throw new Error("not found") + + const canEdit = await db.products.canEdit({ productId: image.productId, userId: user?.id }) + if (!canEdit) throw new Error("forbidden") + + await db.productImages.delete(imageId) + await bucket.del(image.path) +} +``` + +Deleting a product is the same thing in bulk, but the order flips: objects first, rows second. The rows are the only record of the paths, so they have to survive a failed object delete for a retry to find them again, and nothing links a product that is being deleted, so there is no window where a page shows a broken image. If it fails partway, run it again: `del` treats a missing object as success. + +```ts +export async function deleteProduct(productId: string) { + const user = await getUser() + if (!(await db.products.canEdit({ productId, userId: user?.id }))) throw new Error("forbidden") + + const images = await db.productImages.findMany({ productId }) + + await bucket.del(images.map((image) => image.path)) + await db.productImages.deleteMany({ productId }) + await db.products.delete(productId) +} +``` + +An array is sent as batch deletes, and if any object survives, `del` throws `partial_delete` with the remaining paths in `failed`. See [Deleting](/blob/bucket/deleting). + +--- + +## Next steps + + + + `immutable`, `revalidate`, and the versioned URL pattern in full. + + + + `uploadRoute`, `input`, `state`, and multiple routes on one endpoint. + + + + One path, an array, a prefix, and what a partial delete reports. + + + + One image per row, overwritten in place. + + diff --git a/blob/recipes/site-assets.mdx b/blob/recipes/site-assets.mdx new file mode 100644 index 00000000..605a7c7f --- /dev/null +++ b/blob/recipes/site-assets.mdx @@ -0,0 +1,234 @@ +--- +title: "Site Assets and CMS Media" +--- + + + Choose a cover image in the admin form, watch it upload, fill in the alt text and save, then replace it and see the site preview update on a new path + Choose a cover image in the admin form, watch it upload, fill in the alt text and save, then replace it and see the site preview update on a new path + + +Files that belong to the site rather than to a user: the images and brochures an editor uploads from an admin page, and the fonts, stylesheets and downloads your build ships. Nobody owns them, everybody reads them, and they should be cached for as long as possible. + +Three choices make that work: + +- **An editor-only upload route.** The role check runs in `onBeforeUpload`, before anything is signed. +- **A row per media file.** Path, URL, alt text and uploader live in your table. The bucket holds bytes, your database is the index. +- **A path that changes when the bytes do.** `uniquePath` for uploads, a versioned filename for build assets, so `cache: 'immutable'` is always honest. + +This recipe uses a public bucket, since every one of these files is meant to be linked from a page. If you have not created one yet, start with the [Quickstart](/blob/overall/quickstart). If you only need the deploy script, skip to [Build-time assets](#build-time-assets). + +--- + +## The media handler + +Editors upload from the browser, so the bytes go straight to storage and your server only authorizes them. + +```ts lib/uploads.ts +import "server-only" +import { BlobError, uniquePath, uploadHandler } from "@upstash/blob" +import { getUser } from "./auth" +import { db } from "./db" + +export const uploads = uploadHandler({ + constraints: { contentTypes: ["image/*", "application/pdf"], maxSize: "25mb" }, + + onBeforeUpload: async ({ request, file }) => { + const user = await getUser(request) + if (!user) throw new BlobError("unauthorized") + if (user.role !== "editor") throw new BlobError("forbidden") + + return { + path: uniquePath`media/${file.name}`, + cache: "immutable", + metadata: { uploader: user.id }, + } + }, + + onUploadComplete: async ({ uploadId, path, url, file, metadata }) => { + try { + // The browser retries this request on a flaky network, so upsert on uploadId. + await db.media.upsert({ + id: uploadId, + name: file.name, + alt: "", + path, + url, + uploaderId: metadata.uploader, + }) + } 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 file, try again" }) + } + return { mediaId: uploadId, url } + }, +}) +``` + +`uniquePath` gives every upload its own object, so re-uploading a file called `hero.png` never replaces last month's `hero.png`. Because the path never repeats, `cache: 'immutable'` needs no invalidation at all. + +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 +import { uploads } from "@/lib/uploads" + +export const { GET, POST } = uploads +``` + +```ts lib/upload-hooks.ts +"use client" + +import { uploadHooks } from "@upstash/blob/react" +import type { uploads } from "./uploads" + +export const { useUpload } = uploadHooks() +``` + +--- + +## The admin uploader + +Alt text is not a property of the object, it is a property of the row, so it is filled in after the upload lands. + +```tsx components/media-uploader.tsx +"use client" + +import { useUpload } from "@/lib/upload-hooks" +import { setAltText } from "@/app/actions" + +export function MediaUploader() { + const { start, upload, accept } = useUpload() + + return ( + <> + start({ file: e.target.files?.[0] })} /> + {upload?.pending && } + {upload?.status === "error" &&

{upload.error.message}

} + {upload?.status === "done" && ( +
setAltText(upload.blob.data.mediaId, String(data.get("alt")))}> + + + +
+ )} + + ) +} +``` + +```ts app/actions.ts +"use server" + +import { getUser } from "@/lib/auth" +import { db } from "@/lib/db" + +export async function setAltText(id: string, alt: string) { + const user = await getUser() + if (user?.role !== "editor") throw new Error("forbidden") + await db.media.update({ id, alt }) +} +``` + +The rendered page reads the row and never asks the bucket anything: + +```tsx +const cover = await db.media.find({ id: post.coverMediaId }) + +{cover.alt} +``` + +--- + +## Build-time assets + +Fonts, compiled CSS and static downloads have no editor and no row. They are written by a script at deploy time, to paths you choose yourself, and the path is the identifier. + +```ts scripts/upload-assets.ts +import { readFile } from "node:fs/promises" +import { Bucket } from "@upstash/blob" + +const bucket = Bucket.fromEnv() + +const assets = [ + { path: "assets/fonts/inter.woff2", file: "build/fonts/inter.woff2", contentType: "font/woff2" }, + { path: "assets/css/app.v3.css", file: "build/app.css", contentType: "text/css" }, + { path: "assets/downloads/whitepaper.pdf", file: "static/whitepaper.pdf", contentType: "application/pdf" }, +] + +for (const asset of assets) { + const blob = await bucket.put(asset.path, await readFile(asset.file), { + contentType: asset.contentType, + cache: "immutable", + }) + console.info(`[assets] ${blob.path}, ${blob.size} bytes`) +} +``` + +A file read from disk is a buffer, which carries its length but not its type, so declare `contentType` yourself. Run the script from your deploy command, after the build and before the site goes live. + +--- + +## When one of them changes + +`cache: 'immutable'` asks browsers and CDNs to keep the bytes for a year, so a changed file needs a path nothing has seen before: put the version in the filename, as `app.v3.css`, or use a content hash. The old object stays until you delete it, which is what makes a rollback free. + +When a path genuinely has to stay stable, overwrite it and link `versionedUrl` instead of `url`. It is the same URL with the object's etag on the query, so it changes whenever the bytes do. Wherever your templates get asset URLs from, a generated manifest or an env var, write `versionedUrl` there: + +```ts +const blob = await bucket.put("assets/logo.svg", buffer, { contentType: "image/svg+xml", cache: "immutable" }) + +blob.versionedUrl // link this, not blob.url +``` + +The trade-off between the two, and the `revalidate` option for a URL you cannot version at all, is in [Caching](/blob/bucket/caching). + +--- + +## Removing an asset + +For CMS media, delete the row first, then the object. The page stops linking the file immediately, and if the second step fails the leftover is an object nobody links to rather than a broken image. + +Add this to `app/actions.ts`: + +```ts +import { Bucket } from "@upstash/blob" + +const bucket = Bucket.fromEnv() + +export async function deleteMedia(id: string) { + const user = await getUser() + if (user?.role !== "editor") throw new Error("forbidden") + + const media = await db.media.find({ id }) + if (!media) throw new Error("not found") + + await db.media.delete(id) + await bucket.del(media.path) +} +``` + +A build asset has no row, so there is only the object: `await bucket.del('assets/css/app.v2.css')`. `del` treats an already missing object as success, so both are safe to run again. + +--- + +## Next steps + + + + `put`, content types, and what bodies carry their own length. + + + + `immutable`, `revalidate`, and the versioned URL pattern in full. + + + + What `image/*` expands to, and why SVG is not in it. + + diff --git a/blob/recipes/video.mdx b/blob/recipes/video.mdx new file mode 100644 index 00000000..d8520dcb --- /dev/null +++ b/blob/recipes/video.mdx @@ -0,0 +1,215 @@ +--- +title: "Video Uploads" +--- + + + Pick a 1.2 GB video, watch four parts upload at once, pause and resume, close the tab and pick the same file to continue from the parts that landed, then play it back + Pick a 1.2 GB video, watch four parts upload at once, pause and resume, close the tab and pick the same file to continue from the parts that landed, then play it back + + +Course lessons, screen recordings, a creator upload form: files of a few hundred megabytes to a few gigabytes, picked in the browser and played back on a page. + +Three choices make that work: + +- **`multipart: true`.** Every video goes up in parts, whatever it weighs, which is what buys pause, resume and per-part retry on a two hour upload. +- **A unique path, cached forever.** `uniquePath` plus `cache: 'immutable'` is one object per video, never overwritten, so its URL can be cached for a year. +- **A row per video.** Written as `pending` before the bytes and flipped to `ready` after them, so a page never links a file that is only half there. + +The bytes go from the browser straight to storage. Your server authorizes the upload and records what landed, and never carries a gigabyte through a function. + +This recipe uses a public bucket. If you have not created one yet, start with the [Quickstart](/blob/overall/quickstart). + +--- + +## The handler + +Unlike the other recipes, the row is written in `onBeforeUpload`, before a byte exists, and marked `pending`. A tab that dies halfway leaves a row still saying `pending`, which is what a cron can find and sweep. `onUploadComplete` only flips it to `ready`. + +```ts lib/uploads.ts +import "server-only" +import * as z from "zod" +import { BlobError, uniquePath, uploadHandler, uploadRoute } from "@upstash/blob" +import { getUser } from "./auth" +import { db } from "./db" + +const video = uploadRoute()({ + constraints: { contentTypes: ["video/*"], maxSize: "5gb" }, + multipart: true, + input: z.object({ title: z.string().min(1) }), + + onBeforeUpload: async ({ request, input, file }) => { + const user = await getUser(request) + if (!user) throw new BlobError("unauthorized") + + const videoId = crypto.randomUUID() + const path = uniquePath`videos/${user.id}/${file.name}` + + await db.videos.insert({ + id: videoId, + ownerId: user.id, + title: input.title, + path, + status: "pending", + }) + + return { path, cache: "immutable", state: { videoId } } + }, + + onUploadComplete: async ({ state, path, url, size }) => { + try { + // Last. The row stops looking abandoned only once everything else is written. + await db.videos.update(state.videoId, { url, size, status: "ready" }) + } 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 video, try again" }) + } + return { videoId: state.videoId } + }, +}) + +export const uploads = uploadHandler({ routes: { video } }) +``` + +`onBeforeUpload` runs once per upload, so one upload is one row, and the row is the placeholder the rest of the app renders while the upload runs. A resume does not run it again: if the tab closes and the user picks the same file later, the SDK sends only the missing parts, and `onUploadComplete` flips the row that already exists. + +`multipart: true` also means nothing is stored at the path until the upload completes. A tab that dies halfway leaves parts, which `abortStaleMultipartUploads()` on a cron clears, and a row still `pending`, which the same cron deletes once the parts are gone. See [Abandoned uploads](/blob/uploads/abandoned-uploads#the-cron). + +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 + +```ts app/api/upload/route.ts +import { uploads } from "@/lib/uploads" + +export const { GET, POST } = uploads +``` + +```ts lib/upload-hooks.ts +"use client" + +import { uploadHooks } from "@upstash/blob/react" +import type { uploads } from "./uploads" + +export const { useUpload } = uploadHooks() +``` + +--- + +## The upload component + +A gigabyte takes minutes, so the controls matter more than they do for a picture. + +```tsx components/video-upload.tsx +"use client" + +import { useUpload } from "@/lib/upload-hooks" + +export function VideoUpload({ title }: { title: string }) { + const { start, upload, accept } = useUpload("video") + + return ( + <> + start({ file: e.target.files?.[0], input: { title } })} + /> + + {upload?.pending && } + {upload?.status === "finishing" &&

Finishing up...

} + {upload?.stalled &&

Connection is struggling, retrying...

} + + {upload?.canPause && upload.status === "uploading" && ( + + )} + {upload?.status === "paused" && } + {upload?.pending && } + + {upload?.status === "error" && ( + + )} + + ) +} +``` + +`canPause` is true throughout because the route sets `multipart: true`. `percent` sits at 99 while `onUploadComplete` runs, which is why `finishing` gets its own line rather than a bar that looks stuck. If the tab is closed and the user picks the same file again later, the upload resumes from the parts that landed, with no API to call. [Large files](/blob/uploads/large-files) covers all of that. + +--- + +## Playing it back + +Render the URL from your own row, and let `status` keep half-uploaded videos off the page. + +```tsx components/video-player.tsx +import { db } from "@/lib/db" + +export async function VideoPlayer({ id }: { id: string }) { + const video = await db.videos.find({ id, status: "ready" }) + if (!video) return

Still processing...

+ + return