SDK for Upstash Blob: server uploads, direct browser uploads, and React hooks.
npm install @upstash/blobimport { Bucket } from '@upstash/blob';
const bucket = Bucket.fromEnv(); // reads UPSTASH_BLOB_TOKEN, or new Bucket({ token })
const blob = await bucket.put('avatars/me.png', file, { contentType: 'image/png' });
blob.url; // undefined on a private bucket
blob.etag;
await bucket.get('avatars/me.png'); // record + ReadableStream body
await bucket.info('avatars/me.png'); // same record, no bytes
await bucket.exists('avatars/me.png'); // false instead of a throw
await bucket.list({ prefix: 'avatars/', limit: 100 }); // { blobs, cursor }
await bucket.copy('tmp/9f3c', 'avatars/7.png');
await bucket.move('tmp/9f3c', 'avatars/7.png', { contentType: 'image/png' });
await bucket.del('a.png'); // or ['a.png', 'b.png'], or { prefix: 'tmp/' }
await bucket.updateJson<Settings>('u/7.json', (prev) => ({ ...(prev ?? {}), theme: 'dark' }));getandinfothrownot_foundrather than returningundefined;listreturns keys, sizes, etags and urls, without metadata.cursoris set only while more remains.- A body over 16 MB goes up as multipart;
{ multipart: '100mb' | true | false }moves the line.allowOverwrite: falseandifUnchangedare single-PUT only. updateJsonis a compare-and-set loop (If-Match, orIf-None-Match: *when nothing is there), retried on conflict with a short jittered pause, up tomaxAttemptstimes (default 6).copyandmovetake{ contentType, cache, metadata }; whatever is not given is carried over from the source. Storage has no rename, somoveis a copy plus a delete: a failed delete throwsmove_left_a_copy, destination kept.del({ prefix: '' })needsall: true. A partial array delete throwspartial_delete.- Metadata is printable ASCII; anything else is refused with
invalid_input. cachetakes'immutable','revalidate','no-store', a duration, or a verbatim header.Bucket.fromEnv()readsUPSTASH_BLOB_TOKEN. It takes the constructor's options alone,fromEnv({ cache: 'immutable' }), or a variable name first,fromEnv('MEDIA_TOKEN', { cache }).
const { url, expiresAt } = await bucket.signedReadUrl('private/report.pdf', { downloadAs: 'report.pdf' });
const upload = await bucket.signedUploadUrl('u/7/report.pdf', { contentType: 'application/pdf', size });
await fetch(upload.url, { method: 'PUT', headers: upload.headers, body });Upstash signs each link for one operation on one object, and it lives at most 10 minutes (default
read: 5 minutes, upload: 10; a longer expiresIn gets 10). headers on an upload URL are pinned
into the signature. A path with an empty segment (dir/, a//b) cannot be signed.
bucket.publicUrl(path) is undefined on a private bucket.
Whether a bucket is private is decided in the console, not in code: the SDK learns it from the
backend on the first request, and a private bucket has no url or versionedUrl on any
BlobObject. Reads there go through signedReadUrl().
A multipart upload that was never finished is invisible to list() and blocks bucket deletion. The
SDK aborts the ones it knows about; a closed tab leaves one behind, so run a cron:
await bucket.abortStaleMultipartUploads({ olderThan: '1d', prefix: 'uploads/' });Under the threshold there is nothing to abort: the browser's PUT already stored the object, so an
abandoned upload is an ordinary, billed, listable object. The upstash-upload metadata key stays on
accepted objects too, so it says "came from that upload", never "no callback took it" -- track state
in your own rows (pending in onBeforeUpload, ready last in onUploadComplete, sweep the rest), or
set multipart: true so nothing is stored until the handler completes it.
Bytes go straight to storage; your server only authorizes, signs, and records.
// lib/uploads.ts
import 'server-only';
import { BlobError, uniquePath, uploadHandler } from '@upstash/blob';
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'); // the 401; nothing is signed
return { path: uniquePath(`${user.id}/${file.name}`), metadata: { owner: user.id } };
},
onUploadComplete: async ({ metadata, url, uploadId }) => {
// uploadId is stable across retries, so the same completion twice writes one row
await sql`insert into files (upload_id, owner, url)
values (${uploadId}, ${metadata.owner}, ${url})
on conflict (upload_id) do nothing`;
},
});
// app/api/upload/route.ts
export const { GET, POST } = uploads;uniquePath adds an eight-character random suffix to the final filename and keeps everything
else as given: Alice_123/Q3 Report.pdf becomes Alice_123/Q3 Report-<random>.pdf. It does not
lowercase, normalize Unicode, trim or truncate values, and it accepts slashes anywhere, so a
prefix goes in as is: uniquePath(`${prefix}${user.id}/${file.name}`). Paths with . or .. segments are refused
when used. Store the returned path and authorize reads and deletes using the stored owner and
exact path.
'use client';
import { uploadHooks } from '@upstash/blob/react';
export const { useUpload } = uploadHooks<typeof uploads>();
const { start, upload, accept } = useUpload();
<input type="file" accept={accept} onChange={(e) => start({ file: e.target.files?.[0] })} />;routes: { attachment: {...}, large: {...} }mounts several routes at one endpoint;useUpload('attachment')picks one. Route options replace handler defaults key by key.context: (request) => ...runs once per POST, before any body is read, and its value isctxin every callback.onBeforeUploadonly runs on the first request of an upload, socontextis how the user reachesonUploadCompleteandonErrortoo, and how several routes share one auth check. With a single route, authorizing inonBeforeUploadand carrying an id inmetadatais shorter.- No
bucketreadsUPSTASH_BLOB_TOKEN, likeBucket.fromEnv(). Passbucket:when the token is under another variable, the bucket needscache, or you are on Workers, where the token only exists on the request'senv. GETserves the constraints with an ETag andmax-age=60, foracceptand an early refusal. The server is authoritative.- A file under 16 MB is one presigned PUT, larger is multipart; only parts can pause, resume and
retry, so
canPauseis false for a single PUT.multipartmoves that line per handler or route. - Records carry
status,percent,blob,error, andpending(queued, uploading, finishing, paused).blob.datais typed from that route'sonUploadComplete. contentTypesalso checks the file's first bytes atbegin. Ergonomics, not a control: part bodies never reach your server. Not malware scanning.- The signed PUT sends
Content-Type,Cache-Controlandx-amz-meta-*as real headers, so bucket CORS must allow them from your origin. onUploadCompleteis at-least-once, keyed by a stableuploadId. A throw deletes the completed object, so catch your own database errors instead of letting them escape.uploadRoute()adds a Standard Schemainputand typedstate. Without React:upload(file, { route: '/api/upload' })from@upstash/blob/browser.
For bytes that must pass through your app, write an ordinary route and call bucket.put. Keep the
cap under the platform's body limit; pass size for an unknown-length stream to avoid buffering.
useServerUpload('/api/avatar') from @upstash/blob/react sends one POST with progress,
cancellation and BlobError decoding, and returns the route's JSON unchanged.
const { endpoint, region, bucket: name, credentials } = bucket.s3();
const s3 = new S3Client({ endpoint, region, credentials });endpoint and credentials are async providers, so the aws-sdk re-reads the short-lived credential
on expiry. Do not presign with it: the URL would carry the credential, and with it every object in
the bucket until it expires. Use signedReadUrl() and signedUploadUrl().
Everything throws a BlobError with a code from a closed list, a status, and a printable
message. Use BlobError.is(e), not instanceof: an ESM and a CJS copy are two classes. A route
answers with e.toJSON() and the browser rebuilds it, so error.code in a hook is the code the
server raised. formatBytes is exported from all three entrypoints and displays decimal byte units.
Size options always count bytes, never bits. Decimal units (KB, MB, GB, TB) use powers of
1,000; explicit binary units (KiB, MiB, GiB, TiB) use powers of 1,024. Unit names ignore
case. For a 32 MiB file limit, use constraints: { maxSize: '32MiB' } or maxSize: 33_554_432.
'32mb' remains 32,000,000 bytes; it is not large enough for a 32 MiB file.
if (BlobError.is(e) && e.code === 'too_large') showError(e.message);Requests carry the SDK version, runtime, and platform. Set UPSTASH_DISABLE_TELEMETRY or pass
enableTelemetry: false.
Read bundled docs in node_modules/@upstash/blob/docs/ and source in
node_modules/@upstash/blob/src/. For SDK guidance in your coding agent, install the Blob agent skill:
pnpx skills add upstash/skills --skill upstash-blob-jsSee installation instructions for agent-specific options.
Use pnpm 10.33.0 for dependency management and Bun for the test runner, matching the other SDKs. Dependencies have a seven-day minimum release age.
pnpm install --frozen-lockfile
pnpm run check
pnpm run test:unitpnpm run test:live runs against a real bucket and requires Blob credentials.
prepack fetches the latest Blob docs with giget when packing or publishing. The generated
docs/ folder is gitignored and replaced on each pack.