Vercel Blob
Store files in Vercel Blob with files-sdk, using OIDC on Vercel (BLOB_STORE_ID, no secret) or a BLOB_READ_WRITE_TOKEN anywhere, public or private.
Installation
@vercel/blob is an optional peer dependency of files-sdk - install alongside the SDK so the adapter’s imports resolve at runtime.
npm install files-sdk @vercel/blobpnpm add files-sdk @vercel/blobyarn add files-sdk @vercel/blobbun add files-sdk @vercel/blobnub add files-sdk @vercel/blobaube add files-sdk @vercel/blobUsage
import { Files } from "files-sdk";
import { vercelBlob } from "files-sdk/vercel-blob";
// On Vercel, the connected Blob store authenticates through OIDC. Anywhere
// else, set BLOB_READ_WRITE_TOKEN. No options needed for either.
const files = new Files({ adapter: vercelBlob() });
await files.upload("avatars/abc.png", file);
const url = await files.url("avatars/abc.png");
Authentication
Vercel Blob takes one of two credentials, and the adapter picks whichever it finds without any options:
| Credential | Comes from | Use it for |
|---|---|---|
| OIDC (recommended) | BLOB_STORE_ID, plus a short-lived token: per request on Vercel Functions, VERCEL_OIDC_TOKEN in builds and local development |
Code running on Vercel |
| Read-write token | BLOB_READ_WRITE_TOKEN (vercel_blob_rw_…) |
Scripts, CI, and servers outside Vercel |
OIDC on Vercel
OIDC needs no secret in your code or your environment variables. Connect the Blob store to the project in the Vercel dashboard, and the deployment gets BLOB_STORE_ID plus an OIDC token that Vercel rotates on its own. On Vercel Functions the token arrives with each request, in the x-vercel-oidc-token header, rather than as an environment variable. The adapter hands just the store id to @vercel/blob, which reads the current token from the request on every call, so vercelBlob() works the same at module scope as inside a handler. That holds when the project also has BLOB_READ_WRITE_TOKEN: the request’s OIDC token is used first.
Because the token is short-lived, a leaked one stops working within hours, where a leaked read-write token works until you rotate it by hand.
Read-write token
A read-write token grants full access to one store and doesn’t expire. Find it on the store’s page in the Vercel dashboard (Storage, then your Blob store), or run vercel env pull in a linked project to write it to .env.local. Set it as BLOB_READ_WRITE_TOKEN, or pass it as token:
const files = new Files({
adapter: vercelBlob({ token: process.env.BLOB_READ_WRITE_TOKEN }),
});
Keep it on the server. To let a browser upload, hand it a signed upload URL or put the gateway in front of the adapter, and never ship the token itself.
Local development
vercel env pull writes VERCEL_OIDC_TOKEN and BLOB_STORE_ID to .env.local, so OIDC works locally too. The local token expires after 12 hours. @vercel/blob 2.5 and later refresh it automatically in a project linked with vercel link; on 2.4, run vercel env pull again. A read-write token in .env.local works as well and never expires.
If your framework doesn’t load .env.local into process.env (Vite, for example), pass the values explicitly:
const files = new Files({
adapter: vercelBlob({
oidcToken: import.meta.env.VERCEL_OIDC_TOKEN,
storeId: import.meta.env.BLOB_STORE_ID,
}),
});
An explicit oidcToken is used as given, so it isn’t refreshed.
Which credential wins
The adapter follows @vercel/blob’s own order, checked on every call:
- A
tokenoption. - An
oidcTokenoption, withstoreId(option orBLOB_STORE_ID). - An OIDC token
@vercel/blobfinds itself, withstoreId(option orBLOB_STORE_ID): the request’sx-vercel-oidc-tokenheader first, thenVERCEL_OIDC_TOKEN. BLOB_READ_WRITE_TOKEN.
With BLOB_STORE_ID and BLOB_READ_WRITE_TOKEN both set, a Vercel Function authenticates with the request’s OIDC token, and the read-write token is used only where no OIDC token turns up, such as a script or a server outside Vercel. Without a store id there is no OIDC, so the read-write token is used even inside a request.
Credential errors
vercelBlob adapter: missing credentialsmeans nothing could authenticate. The adapter throws it when created with none oftoken,oidcToken,BLOB_READ_WRITE_TOKEN, orBLOB_STORE_IDavailable. With only a store id, it can’t know yet whether an OIDC token will arrive, so the first operation that finds none throws it instead (@vercel/blob’s own “No blob credentials found” error is itscause). That’s typically code running outside Vercel, or outside a request on Vercel, for example at import time. Off Vercel, setBLOB_READ_WRITE_TOKENor runvercel env pull. On Vercel, check that the Blob store is connected to the project and environment you deployed.`oidcToken` was passed but no `storeId` was foundmeans OIDC was requested explicitly without a store id. PassstoreIdor setBLOB_STORE_ID.- A
401orUnauthorizederror with OIDC locally usually means the local token expired. Runvercel env pullagain, or upgrade to@vercel/blob2.5 or later. A token passed asoidcTokenis never refreshed.
Public and private blobs
access selects public or private blobs and is fixed at construction. Defaults to "public". With access: "private", uploads use Vercel’s private mode and reads route through blob.get() with whichever credentials the adapter resolved (OIDC or read-write token) instead of a public URL fetch. There is no permanent public URL for private blobs, so url() mints a Vercel Signed URL instead - a presigned GET scoped to that key that expires after expiresIn seconds (1 hour by default, defaultUrlExpiresIn changes the default). Need both? Use two adapters.
downloadTimeoutMs bounds the body reads issued by download() (the public-URL fetch, or blob.get() in private mode). Defaults to 5 minutes; pass 0 to disable. A hung CDN response would otherwise leak a fetch that never resolves.
Signed URLs
Vercel Signed URLs (@vercel/blob 2.4.0+) back two methods:
url()in private mode issues a signed token scoped to the key and thegetoperation, expiring with the URL, then signs the URL locally. One control-API round trip per call. Vercel caps the lifetime at 7 days server-side.signedUploadUrl()in either mode returns a presignedPUT.contentTypeandmaxSizeare enforced by Vercel’s CDN, so they are real constraints, not advisory headers. There is no minimum-size counterpart: a positiveminSizethrowsUnsupportedrather than handing out a URL that accepts the empty upload you asked to reject. The adapter’saddRandomSuffixandallowOverwritesettings apply to the upload.
const files = new Files({ adapter: vercelBlob({ access: "private" }) });
// Presigned GET, valid for five minutes.
const url = await files.url("invoices/2026-q1.pdf", { expiresIn: 300 });
// Presigned PUT the browser can use directly; Vercel rejects other
// content types and anything over 10 MB at the CDN.
const upload = await files.signedUploadUrl("uploads/photo.png", {
contentType: "image/png",
expiresIn: 600,
maxSize: 10 * 1024 * 1024,
});
// → { method: "PUT", url, headers: { "Content-Type": "image/png" } }
Public blobs keep returning their permanent CDN URL from url() - a presigned copy would add a round trip without restricting anything - so capabilities.signedUrl.supported is true only in private mode. In public mode, url(key, { expiresIn }) throws Unsupported rather than hand back a link that never expires; omit expiresIn for the permanent URL. Neither the CDN URL nor a presigned URL can carry a Content-Disposition override, so responseContentDisposition throws Unsupported in both modes.
Signed uploads don’t depend on the mode: capabilities.signedUpload reports supported, maxSize, and contentType as true for public and private adapters alike. The gateway presigns on that, so browser uploads go straight to Vercel Blob with a presigned PUT in either mode, with your maxUploadSize and the file’s content type enforced at the CDN, rather than streaming through your server.
Options
token?string
Read-write token (or client token). Passed here, it wins over every other credential, OIDC included, matching `@vercel/blob`. Without this option the adapter falls back to `process.env.BLOB_READ_WRITE_TOKEN`, but only as the last resort: with a store id configured, an OIDC token found for the call is used first. Environment credentials are resolved on every operation, so changes made after construction are honored. On Vercel, prefer OIDC and leave this unset.
stringoidcToken?string
Vercel OIDC token. Usually left unset: with a store id (`storeId` or `BLOB_STORE_ID`) and no `token`, `@vercel/blob` looks the token up on every call, the way it does when called directly. On Vercel Functions the token arrives per request in the `x-vercel-oidc-token` header, not in `process.env`; elsewhere it comes from `VERCEL_OIDC_TOKEN` (set during Vercel builds, and written to `.env.local` by `vercel env pull`). `@vercel/blob` 2.5 and later refresh an expired local token in a project linked with `vercel link`. OIDC tokens are short-lived and auto-rotated, so they remove the risk that a long-lived `BLOB_READ_WRITE_TOKEN` leaks from your codebase or environment. Pass `oidcToken` explicitly only when you obtain the token yourself, for example when your framework doesn't load `.env.local` into `process.env` (Vite, etc.). A token passed here is used as given, never refreshed, and needs a store id: without one the adapter throws rather than falling back to the read-write token.
stringstoreId?string
Blob store id, used with OIDC. Defaults to `process.env.BLOB_STORE_ID`. Accepted in either `store_<id>` or `<id>` form (mirrors the SDK). Independently powers the `url()` fast path: when a `storeId` is known (from option, env, or derived from a `vercel_blob_rw_<storeId>_…` token), public URLs are synthesized without a round trip if `addRandomSuffix: false`.
stringaccess?"public" | "private"
Whether blobs uploaded by this adapter are public or private. - `"public"` (default): blobs are uploaded with `access: "public"` and reachable via their CDN URL without authentication. `url()` returns a permanent public URL; `url(key, { expiresIn })` throws `Unsupported`. - `"private"`: blobs are uploaded with `access: "private"`. They cannot be fetched by their plain URL — `download()` instead routes through `blob.get(key, { access: "private" })`, which uses whichever credentials the adapter resolved (read-write token or OIDC). `url()` mints a presigned GET URL (Vercel Signed URLs) that expires after `expiresIn` seconds. `signedUploadUrl()` mints a presigned PUT URL in either mode. The setting is fixed at construction so a single `Files` instance is unambiguously one or the other. If you need both, instantiate two adapters.
"public" | "private"defaultUrlExpiresIn?number
Default expiry, in seconds, for the presigned URLs `url()` mints in `access: "private"` mode. Defaults to 3600 (1 hour). Per-call `url(key, { expiresIn })` overrides. Vercel caps signed-URL lifetime at 7 days server-side. Ignored in `"public"` mode, where `url()` returns the permanent CDN URL.
numberaddRandomSuffix?boolean
Add a random suffix to uploaded keys (Vercel default). When `false`, the resulting pathname matches the key 1:1, which keeps the API consistent with S3/R2 where callers expect to control the key. Defaults to `false`.
booleanallowOverwrite?boolean
Allow overwriting existing keys on upload. Defaults to `true` so that the "predictable keys" behavior (`addRandomSuffix: false`) actually works — Vercel rejects same-pathname uploads otherwise. **Trade-off:** with the defaults, an `upload(key, ...)` call silently clobbers any existing object at `key`. If keys are derived from untrusted input or your callers expect "create-only" semantics, set `allowOverwrite: false` and handle the resulting Conflict (Vercel reports the existing blob as a bad request, which the adapter maps to `Conflict`). Applies to `upload()` (including resumable/multipart uploads), `copy()`, and `signedUploadUrl()`.
booleandownloadTimeoutMs?number
Timeout in milliseconds for the public-URL fetches and private `blob.get` reads issued by `download()`. A hung CDN response would otherwise leak a fetch that never resolves. Defaults to 300_000 (5 minutes). Pass `0` to disable the timeout (not recommended in server contexts — a stuck request will pin a connection until the runtime tears it down).
numberLimitations
User metadata isn’t supported by the underlying API, so passing a non-empty metadata throws Unsupported rather than silently dropping it. cacheControl is supported through its max-age directive, which maps to the blob’s cacheControlMaxAge; Vercel writes the rest of the header itself. A value with no max-age (e.g. "no-store") throws Unsupported rather than being dropped.
With allowOverwrite: false, uploading to an existing key throws a Conflict.
Compatibility
Public access
| Method | Status | Notes |
|---|---|---|
upload |
✅ | |
download |
✅ | The body is fetched from the blob URL with a v=<etag> query parameter taken from a fresh head(), so a read right after an overwrite gets the new bytes rather than the CDN’s cached copy (the CDN caches each version once). A 401/403 on the body fetch is Unauthorized, and a 409/412 Conflict. |
delete |
✅ | |
list |
⚠️ | Listed items’ contentType is always application/octet-stream (the list API returns no content type); use head() for the real type. |
search |
✅ | |
head |
✅ | |
exists |
✅ | |
copy |
✅ | Server-side blob.copy. The source’s content type and cache max-age are read with head() and passed on, since blob.copy would otherwise give the copy a type guessed from its extension and the default one-month cache. |
url |
⚠️ | Returns the permanent CDN URL. expiresIn throws Unsupported (the URL can’t expire); responseContentDisposition throws (no Content-Disposition override available). Use a different provider for buckets with untrusted user-uploaded content. |
signedUploadUrl |
✅ | Presigned PUT via Vercel Signed URLs. contentType and maxSize are enforced at the CDN; a positive minSize throws. |
Private access
| Method | Status | Notes |
|---|---|---|
upload |
✅ | |
download |
⚠️ | Reads go through blob.get() with useCache: false, straight from origin storage, so a read right after an overwrite gets the new bytes. blob.get() has no range primitive, so range throws. A 401/403 on the read is Unauthorized, and a 409/412 Conflict. |
delete |
✅ | |
list |
⚠️ | Listed items’ contentType is always application/octet-stream (the list API returns no content type); use head() for the real type. |
search |
✅ | |
head |
✅ | |
exists |
✅ | |
copy |
✅ | Server-side blob.copy. The source’s content type and cache max-age are read with head() and passed on, since blob.copy would otherwise give the copy a type guessed from its extension and the default one-month cache. |
url |
⚠️ | Presigned GET via Vercel Signed URLs, honoring expiresIn (1 hour default, 7-day server-side cap). responseContentDisposition throws (no Content-Disposition override available). |
signedUploadUrl |
✅ | Presigned PUT via Vercel Signed URLs. contentType and maxSize are enforced at the CDN; a positive minSize throws. |