upstash-blob-js — Claude Code skill: @upstash/blob storage
A skill for the @upstash/blob object storage SDK: direct browser uploads, presigned URLs, multipart and signed reads.
npx skills add upstash/skills --skill upstash-blob-js -a claude-codeWhat upstash-blob-js does
The skill covers the @upstash/blob TypeScript/JavaScript SDK for S3-compatible object storage. Bucket runs on your server, while uploadHandler and React hooks send bytes from the browser straight to storage, so they never pass through your app and platform request body caps don't apply.
The npm package ships docs in node_modules/@upstash/blob/docs/ and readable TypeScript source in node_modules/@upstash/blob/src/. The skill tells the agent to read and search those before writing or debugging SDK code. Install the package with npm install @upstash/blob and set UPSTASH_BLOB_TOKEN. That token is a bearer secret for the whole bucket, so keep it server side and out of NEXT_PUBLIC_ or any bundler-inlined variable.
Whether a bucket is public or private is a console setting, not a client option. A private bucket has no URL, and reads go through signedReadUrl. Cache that link until expiresAt rather than a deadline you compute, because a link can't outlive the credential that signed it and you may get less time than you asked for. Sizes are decimal: '20mb' is 20,000,000 bytes, and '5mib' throws.
What it helps with
- upload avatars or documents from the browser straight to storage
- hand out a time-limited link to a private file
- stream large videos and images with pause and resume
- set cache headers on stored objects
- clean up temporary files under a prefix
Example prompts for the agent
- Set up avatar uploads with @upstash/blob: the browser uploads directly and the server only authorizes
- Generate a signedReadUrl for private/report.pdf that expires in 2m and downloads as Report Q3.pdf
- Add immutable cache and owner metadata to objects in the bucket
How to install upstash-blob-js
1Skills CLI (Vercel)
npx skills add upstash/skills --skill upstash-blob-js -a claude-code2Manually: put the files in place
~/.claude/skills/upstash-blob-js/SKILL.mdCopy the whole skill folder; for one project use .claude/skills/upstash-blob-js/
1Skills CLI (Vercel)
npx skills add upstash/skills --skill upstash-blob-js -a codex2Manually: put the files in place
.agents/skills/upstash-blob-js/SKILL.mdFor all projects use ~/.agents/skills/
1Skills CLI (Vercel)
npx skills add upstash/skills --skill upstash-blob-js -a cursor2Manually: put the files in place
.cursor/skills/upstash-blob-js/SKILL.mdCursor also reads .agents/skills/ and .claude/skills/
1GitHub CLI
gh skill install upstash/skills upstash-blob-js2Manually: put the files in place
.github/skills/upstash-blob-js/SKILL.md1Skills CLI (Vercel)
npx skills add upstash/skills --skill upstash-blob-js -a gemini-cli2Platform CLI
gemini skills install https://github.com/upstash/skills --path skills/upstash-blob-jsManually: put the files in place
.devin/skills/upstash-blob-js/SKILL.mdThe legacy .windsurf/skills/ path still works
Skills CLI (Vercel)
npx skills add upstash/skills --skill upstash-blob-js -a opencodeClaude.ai
Zip the folder and upload it in Settings → Capabilities → Skills
Step by step, with commands for each platform: How to install skills in Claude Code: five ways
Questions about upstash-blob-js
How do I install upstash-blob-js in Claude?
Run npx skills add upstash/skills --skill upstash-blob-js -a claude-code. The skill lands in ~/.claude/skills/upstash-blob-js/SKILL.md.
What does upstash-blob-js do?
It helps you write and debug code against the @upstash/blob SDK: writing and reading objects, presigned URLs, multipart, and browser uploads that skip your server.
What do I need before using the skill?
The @upstash/blob package and a UPSTASH_BLOB_TOKEN from the console. That token grants access to the whole bucket, so keep it server side.
Source text
SKILL.md · MIT
---
name: upstash-blob-js
description: Work with the @upstash/blob TypeScript/JavaScript SDK for S3-compatible object storage with direct browser uploads, presigned URLs, multipart, and signed reads. Use when storing files or blobs, uploading avatars, images, videos, attachments or user documents, letting a browser upload straight to storage without proxying bytes through a server, generating public or time-limited signed URLs, serving private files, streaming large files with pause and resume, setting cache headers on stored objects, or reaching an S3-compatible bucket from the AWS SDK.
license: MIT
metadata:
author: Upstash
homepage: https://upstash.com
---
# @upstash/blob SDK
S3-compatible object storage. `Bucket` runs on your server; `uploadHandler` plus React hooks upload from the browser straight to storage so the bytes never pass through your app.
## Bundled docs and source
The `@upstash/blob` npm package includes docs in `node_modules/@upstash/blob/docs/` and readable TypeScript source in `node_modules/@upstash/blob/src/`. Before writing or debugging SDK code, read and search the relevant docs and source.
## Install & Setup
```bash
npm install @upstash/blob
```
Create a bucket in the console and set `UPSTASH_BLOB_TOKEN`. The token is a bearer secret for the whole bucket — keep it server side, never in `NEXT_PUBLIC_` or any bundler-inlined variable.
A bucket is **public** (every object has a URL) or **private** (no URL; reads go through `signedReadUrl`). That is a console setting, not a client option — the SDK learns it from the backend.
```ts
import { Bucket } from "@upstash/blob"
export const bucket = Bucket.fromEnv() // reads UPSTASH_BLOB_TOKEN
Bucket.fromEnv({ cache: "immutable" }) // default variable, plus options
Bucket.fromEnv("MEDIA_TOKEN", { cache: "immutable" }) // another variable, plus options
new Bucket({ token: env.UPSTASH_BLOB_TOKEN }) // Workers: no process.env
```
## Writing
```ts
const blob = await bucket.put("reports/q3.pdf", pdf, { contentType: "application/pdf" })
blob.url // public URL, undefined on a private bucket
blob.versionedUrl // url + ?v=<etag>, changes whenever the bytes do
blob.etag // what ifUnchanged takes
```
Bodies: `Request`, `Blob`/`File`, `ArrayBuffer`, typed array, `string`, `ReadableStream`. A stream carries no length — pass `size` (exact, streams through) or `maxSize` (buffers up to the cap), or `put` throws `length_required`.
| Option | Default | What it does |
|--------|---------|--------------|
| `contentType` | the body's, else `application/octet-stream` | What the object is stored as |
| `contentTypes` | any | Allow list, e.g. `["image/*", "application/pdf"]` |
| `maxSize` | none | Refuse a bigger body with `too_large` |
| `cache` | bucket default | `Cache-Control` stored with the object |
| `metadata` | none | `x-amz-meta-*`; lowercase keys, printable ASCII values |
| `allowOverwrite` | `true` | `false` refuses if something is there (`already_exists`) |
| `ifUnchanged` | none | An etag; fails with `conflict` if it changed |
| `multipart` | `'16mb'` | Threshold for going up in parts; `true`/`false` force it |
Sizes are **decimal**: `'20mb'` is 20,000,000 bytes. `'5mib'` throws.
```ts
import { uniquePath } from "@upstash/blob"
uniquePath`${user.id}/${file.name}` // 'u7/holiday-pic-3xK9mBqR.png'
```
Use `uniquePath` for any value you don't control. Each `${}` becomes one slugged filename that can never add a directory, and the finished path gets a random suffix — so two uploads of `photo.png` never collide. The literal parts of the template are passed through as written, so keep `.` and `..` out of them yourself: a path with those segments is refused later, by the call that uses it, with a `TypeError` rather than a `BlobError`.
`bucket.copy(from, to, { contentType, cache, metadata })` and `bucket.move(from, to, options)` preserve source properties you omit. `bucket.updateJson(path, fn, { maxAttempts: 6 })` retries a read-modify-write on conflict with backoff.
## Reading
```ts
const res = await bucket.get("reports/q3.pdf") // record + body: ReadableStream
const info = await bucket.info("reports/q3.pdf") // same record, no bytes (HEAD)
await bucket.exists("avatars/u7.png") // boolean instead of a throw
const page = await bucket.list({ prefix: "avatars/", limit: 1000 })
```
`get`/`info` throw `not_found`. Nothing is buffered — wrap the stream to read it:
```ts
await new Response((await bucket.get("notes/1.md")).body).text()
```
`list` pages with `page.cursor` (set only while more remains) and carries no `contentType` or `metadata`. `prefix` is the only filter — **keep your own table as the index** and treat the bucket as storage, not a queryable store.
```ts
const { url, expiresAt } = await bucket.signedReadUrl("private/report.pdf", {
expiresIn: "2m",
downloadAs: "Report Q3.pdf", // save under this name instead of rendering inline
})
```
Cache the link until `expiresAt`, never a deadline you compute — a link cannot outlive the credential that signed it, so you may get less than you asked for. `await bucket.publicUrl(path)` returns the public URL, `undefined` on a private bucket.
## Deleting
```ts
await bucket.del("avatars/me.png") // one path
await bucket.del(["a.png", "b.png"]) // an array, batched by 1000
await bucket.del({ prefix: "tmp/" }) // everything under a prefix
```
Already-gone counts as success, so deletes are safe to retry. An array or prefix delete where objects survive throws `partial_delete` with them in `e.failed`. `del({ prefix: '' })` is refused unless you pass `all: true`.
## Browser uploads
The handler authorizes and records; the bytes go browser → storage, so platform request body caps don't apply. Supply your application's `getUser` and `db.files.upsert` implementations below.
```ts
// lib/uploads.ts
import "server-only"
import { BlobError, uniquePath, uploadHandler } from "@upstash/blob"
import { getUser } from "@/lib/auth"
import { db } from "@/lib/db"
export const uploads = uploadHandler({
constraints: { maxSize: "20mb", contentTypes: ["image/*", "application/pdf"] },
onBeforeUpload: async ({ request, file }) => {
const user = await getUser(request)
if (!user) throw new BlobError("unauthorized") // nothing is signed
return { path: uniquePath`${user.id}/${file.name}`, metadata: { owner: user.id } }
},
onUploadComplete: async ({ uploadId, path, url, metadata }) => {
if (!metadata.owner) throw new BlobError("unauthorized")
await db.files.upsert({ id: uploadId, owner: metadata.owner, path, url })
return { path } // becomes upload.blob.data
},
})
```
```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<typeof uploads>()
```
```tsx
"use client"
import { useUpload } from "@/lib/upload-hooks"
export function UploadForm() {
const { start, upload, accept } = useUpload()
return <>
<input type="file" accept={accept} onChange={(e) => start({ file: e.target.files?.[0] })} />
{upload?.pending && <progress value={upload.percent} max={100} />}
{upload?.status === "done" && <a href={upload.blob.url}>{upload.blob.data.path}</a>}
{upload?.status === "error" && <p>{upload.error.message}</p>}
</>
}
```
`GET` serves the route's constraints, so `accept` fills the file dialog and an oversized file is refused before any request leaves the browser. `uploadHooks<typeof uploads>()` types route names and completion data at compile time; `import type` keeps server code out of the bundle.
This example assumes a **public** bucket. On a private one `url` is `undefined`, so store `path` instead and hand the client a `bucket.signedReadUrl(path)` when it needs to read.
Two rules that bite:
- **`onUploadComplete` can run more than once.** The browser retries it, so upsert on `uploadId` rather than inserting.
- **A throw out of `onUploadComplete` deletes the object.** That is right for a refusal and wrong for a transient database error — catch your own storage errors.
Files over 16 MB go up in parts, which is what gives `pause()`, `resume()`, per-part retry, and resume-after-reload when the user picks the same file again. `percent` caps at 99 until `status` is `done`; drive UI off `upload.pending`.
Use `multipart: true` on the handler to make every upload multipart. Then a closed tab leaves incomplete parts rather than a stored object nobody recorded, and one cron cleans up:
```ts
await bucket.abortStaleMultipartUploads({ olderThan: "1d" })
```
For routes where the bytes must pass through your app, write an ordinary route calling `bucket.put` and drive it with `useServerUpload` from `@upstash/blob/react`.
## Caching
`cache` is written once, at upload, and stored with the object — changing it means writing the object again.
| Value | Stored |
|-------|--------|
| `'immutable'` | `public, max-age=31536000, immutable` |
| `'revalidate'` | `public, max-age=0, must-revalidate` |
| `'no-store'` | `no-store` |
| a duration (`'15m'`, `3600`) | `public, max-age=<seconds>` |
`immutable` needs a path that changes when the bytes do — either `uniquePath` per upload, or a stable path served through `versionedUrl`. On a private bucket `private` replaces `public`.
## Errors
```ts
import { BlobError } from "@upstash/blob"
if (BlobError.is(e) && e.code === "not_found") return null
```
Use `BlobError.is()`, never `instanceof` — an ESM and a CJS copy are different classes. Codes: `not_found`, `already_exists`, `conflict`, `content_type_not_allowed`, `invalid_input`, `too_large`, `empty_body`, `length_required`, `signature_mismatch`, `unauthorized`, `forbidden`, `rate_limited`, `not_ready`, `partial_delete`, `move_left_a_copy`, `invalid_content_type_pattern`, `mint_backoff`, `request_failed`.
A refusal keeps its code all the way to the browser, so hooks switch on `error.code` rather than status numbers. Bad option values (`'5mib'`, a missing token) throw a `TypeError` where they are written, not a `BlobError` per request.
## S3 clients
```ts
import { GetObjectCommand, S3Client } from "@aws-sdk/client-s3"
const config = bucket.s3()
const s3 = new S3Client(config) // endpoint and credentials are async providers
// config.bucket is the underlying bucket id, available nowhere else
await s3.send(new GetObjectCommand({ Bucket: config.bucket, Key: "reports/q3.pdf" }))
```
Buckets are S3-compatible. Use this for what the SDK doesn't wrap — byte ranges, conditional GETs, tagging. Pass the providers through as they come so the AWS SDK can refresh an expired credential.
© Upstash · MIT · upstash/skills