Fix: Vercel Blob Not Working — put/get/del, handleUpload Browser Flow, Access Modes, and Multipart
Quick Answer
How to fix Vercel Blob errors — BLOB_READ_WRITE_TOKEN missing, put vs handleUpload for browser, public vs private access, multipart upload for large files, expires/signed URLs, list/cursor pagination, and overwriting URLs.
The Error
You call put() and it errors about the token:
BlobAccessError: Vercel Blob requires the BLOB_READ_WRITE_TOKEN environment variable.Or you upload from a server route and run into the body size limit:
413 Payload Too Large
The request body exceeded the size limit.Or browser-direct uploads fail with CORS:
Access to fetch at 'https://blob.vercel-storage.com/...' from origin
'https://my-app.com' has been blocked by CORS policy.Or every upload of cover.jpg creates a new URL instead of replacing:
1st upload → https://blob.vercel-storage.com/cover-abc123.jpg
2nd upload → https://blob.vercel-storage.com/cover-def456.jpg (different!)Why This Happens
Vercel Blob is an S3-compatible object store with a Next.js-friendly SDK. Most issues come from:
- Token model. All operations need
BLOB_READ_WRITE_TOKEN. Vercel injects it automatically in production; you must set it in local dev. - Two upload patterns. Server-side
put()(server uploads on behalf of users) vshandleUpload()(client uploads directly with a short-lived token). Each has different security tradeoffs and size limits. - Filename hashing by default.
put("cover.jpg", file)producescover-randomhash.jpgto avoid CDN cache issues. To keep the same URL, useaddRandomSuffix: false. - Access modes.
public(default) means anyone with the URL can read.private(newer) requires signed URLs for reads.
Fix 1: Set Up the Token
In your Vercel dashboard:
- Storage → Create a Blob store.
- Connect it to your project.
- Vercel sets
BLOB_READ_WRITE_TOKENautomatically.
For local dev, pull it down:
vercel env pull
# Writes .env.local with all project env vars including BLOB_READ_WRITE_TOKEN.Or copy from the Vercel Dashboard → Project → Storage → your blob store → “.env.local” tab.
Install the SDK:
npm install @vercel/blobBasic put/get:
import { put, head, del, list } from "@vercel/blob";
// Upload a file (server-side):
const blob = await put("docs/manual.pdf", file, {
access: "public", // Required
});
console.log(blob.url); // https://blob.vercel-storage.com/docs/manual-abc123.pdf
// Get metadata:
const info = await head(blob.url);
console.log(info.contentType, info.size);
// List blobs:
const { blobs, hasMore, cursor } = await list({ prefix: "docs/" });
// Delete:
await del(blob.url);Pro Tip: The access: "public" parameter is required even though public is the only well-supported mode for most calls. The SDK enforces this for clarity.
Fix 2: Server-Side Upload via API Route
For small files (under the platform limit), upload server-side:
// app/api/upload/route.ts
import { put } from "@vercel/blob";
import { NextResponse } from "next/server";
export async function POST(request: Request) {
const form = await request.formData();
const file = form.get("file") as File;
if (!file) {
return NextResponse.json({ error: "No file" }, { status: 400 });
}
const blob = await put(`uploads/${file.name}`, file, {
access: "public",
});
return NextResponse.json(blob);
}// Client:
async function upload(file: File) {
const form = new FormData();
form.append("file", file);
const response = await fetch("/api/upload", {
method: "POST",
body: form,
});
return response.json(); // { url, pathname, contentType, size }
}Limit: Vercel’s serverless function body limit is ~4.5 MB on Hobby, larger on Pro. For files bigger than the limit, use handleUpload (Fix 3).
Fix 3: Browser-Direct Upload With handleUpload
For files larger than the function body limit, upload directly from the browser:
// app/api/upload/route.ts
import { handleUpload, type HandleUploadBody } from "@vercel/blob/client";
import { NextResponse } from "next/server";
export async function POST(request: Request): Promise<NextResponse> {
const body = (await request.json()) as HandleUploadBody;
try {
const response = await handleUpload({
body,
request,
onBeforeGenerateToken: async (pathname) => {
// Authorize the user; throw if not allowed.
// const user = await getUserFromSession(request);
// if (!user) throw new Error("Unauthorized");
return {
allowedContentTypes: ["image/jpeg", "image/png", "application/pdf"],
tokenPayload: JSON.stringify({
// userId: user.id — pass data through to the completion webhook
}),
};
},
onUploadCompleted: async ({ blob, tokenPayload }) => {
// Called after upload finishes. Persist the URL to your DB.
console.log("Upload complete:", blob.url);
},
});
return NextResponse.json(response);
} catch (err) {
return NextResponse.json({ error: (err as Error).message }, { status: 400 });
}
}// Client:
"use client";
import { upload } from "@vercel/blob/client";
async function handleFileUpload(file: File) {
const blob = await upload(file.name, file, {
access: "public",
handleUploadUrl: "/api/upload",
});
console.log(blob.url);
}The flow:
- Client calls
upload()from@vercel/blob/client. - SDK sends a request to your
/api/uploadroute. - Your route validates (auth, content type) and calls
handleUpload. handleUploadreturns a short-lived signed URL for the browser.- Browser PUTs the file directly to Vercel Blob.
- Vercel Blob calls back to your
/api/uploadwithonUploadCompleted.
This bypasses the serverless function body limit. The browser uploads to Vercel Blob; your server never sees the file bytes.
Common Mistake: Forgetting onUploadCompleted to persist the URL. Without it, you upload files but lose track of them. Save the URL to your DB in the callback.
Fix 4: Disable Random Suffix
By default, put("cover.jpg", ...) produces cover-abc123.jpg. To keep the path:
const blob = await put("cover.jpg", file, {
access: "public",
addRandomSuffix: false,
});
// blob.url ends with "cover.jpg" — no suffixThis makes re-uploads with the same name overwrite the previous file. Useful for:
- User avatars (predictable URL
/avatars/{userId}.jpg). - Static assets you want to update in place.
Common Mistake: Disabling the suffix and then hitting CDN cache issues. Vercel’s CDN caches by URL; replacing the file at the same URL means the CDN may serve the old content for a while. For frequently-updated content, keep the suffix and update your DB with the new URL.
For cache-busting on overwrites:
const blob = await put("cover.jpg", file, {
access: "public",
addRandomSuffix: false,
cacheControlMaxAge: 60, // CDN caches for only 60 seconds
});Fix 5: Pagination With list
import { list } from "@vercel/blob";
let cursor: string | undefined;
const allBlobs: { url: string }[] = [];
do {
const result = await list({
prefix: "uploads/",
limit: 100,
cursor,
});
allBlobs.push(...result.blobs);
cursor = result.hasMore ? result.cursor : undefined;
} while (cursor);list() returns up to limit blobs (default 1000). Use the cursor to paginate.
For listing under a prefix:
const userBlobs = await list({
prefix: `users/${userId}/`,
});This is how you implement “list all of a user’s uploads” without a separate DB index.
Pro Tip: Don’t list to find a blob. list scans the namespace. For lookups, store the blob URL in your DB at upload time and query from there.
Fix 6: Delete and del()
import { del } from "@vercel/blob";
// Delete by URL:
await del("https://blob.vercel-storage.com/uploads/cover-abc123.jpg");
// Delete multiple:
await del([
"https://blob.vercel-storage.com/uploads/a.jpg",
"https://blob.vercel-storage.com/uploads/b.jpg",
]);del is idempotent — calling on a missing blob doesn’t error.
For batch delete by prefix:
const { blobs } = await list({ prefix: `users/${userId}/` });
const urls = blobs.map((b) => b.url);
if (urls.length > 0) {
await del(urls);
}This deletes all of a user’s blobs — useful for account deletion.
Common Mistake: Storing blob URLs in your DB but not deleting from Vercel Blob when records are removed. You’ll accumulate orphaned blobs and pay for them. Always call del in your cleanup logic.
Fix 7: Content Type and Metadata
For non-File uploads (Buffer, ReadableStream):
const blob = await put("data.json", JSON.stringify({ hello: "world" }), {
access: "public",
contentType: "application/json", // Explicit content type
});For images from a third-party source:
const response = await fetch("https://example.com/image.jpg");
const buffer = await response.arrayBuffer();
const blob = await put("imports/image.jpg", Buffer.from(buffer), {
access: "public",
contentType: "image/jpeg",
});For streaming uploads (large generated content):
import { Readable } from "node:stream";
async function* generateData() {
for (let i = 0; i < 10000; i++) {
yield `row-${i}\n`;
}
}
const stream = Readable.from(generateData());
const blob = await put("output.csv", stream, {
access: "public",
contentType: "text/csv",
});For metadata (custom headers exposed on read):
// Currently limited — Vercel Blob doesn't have full custom metadata.
// For app-specific metadata, store in your DB alongside the blob URL.Fix 8: Pricing and Cost Awareness
Vercel Blob is priced on three axes:
- Storage — per GB-month.
- Bandwidth — outbound to clients.
- Operations — read/write/list calls (cheaper than S3 but still counted).
For high-traffic content, the bandwidth dominates. Strategies:
- Cache aggressively at the edge. Vercel’s CDN caches Blob URLs. For long-lived content (images, PDFs),
Cache-Control: public, max-age=31536000, immutableis ideal. - Use random suffixes for cacheable assets. Hash-named files can be cached indefinitely.
- Compress before upload. Don’t upload uncompressed video / large unmodified images.
For monitoring:
// In Vercel dashboard → Storage → your blob store → Usage tab.
// Shows storage, bandwidth, operations over time.For deletion of old data:
import { list, del } from "@vercel/blob";
const cutoff = Date.now() - 30 * 24 * 60 * 60 * 1000; // 30 days
const { blobs } = await list({ prefix: "temp/" });
const oldUrls = blobs
.filter((b) => new Date(b.uploadedAt).getTime() < cutoff)
.map((b) => b.url);
if (oldUrls.length > 0) {
await del(oldUrls);
}Run this on a schedule (Vercel Cron, GitHub Actions) to clean up temp uploads.
Pro Tip: For very large files (videos), consider a CDN-fronted S3 or R2 instead — bandwidth pricing is usually cheaper for high-volume distribution. Vercel Blob excels at “files tied to your Next.js app” where convenience matters more than pure cost.
Still Not Working?
A few less-obvious failures:
BlobAccessErrordespite token set. Token doesn’t match the project. Re-runvercel env pullafter creating the blob store.- CORS errors on browser uploads.
handleUploadhandles CORS automatically. If you’re using a custom upload flow, you’ll have to manage CORS manually. onUploadCompletednot called. Webhook fires from Vercel Blob → your endpoint. In local dev withnext dev, the webhook can’t reachlocalhost. Use a tunnel (ngrok, localtunnel) or test in a preview deploy.- Slow first upload. Initial Blob store creation has some warmup. Subsequent uploads are fast.
Pathname is requiredfromput. First arg must be a non-empty pathname. Random pathnames:put(crypto.randomUUID() + ".jpg", file, ...).- TypeScript: cannot import
@vercel/blob/clientin server file. It’s a client-only entry. Useimport { put } from "@vercel/blob"server-side,import { upload } from "@vercel/blob/client"client-side. - Multipart vs simple uploads. Vercel Blob handles large files automatically via
handleUpload. You don’t manually invoke multipart. - Test environment isolation. Tests against the prod blob store affect prod billing. Use a separate Vercel project for staging.
For related Vercel / storage / upload issues, see Vercel deployment failed, Cloudflare R2 not working, AWS S3 access denied, and Next.js server action not working.
Solo developer based in Japan. Every solution is cross-referenced with official documentation and tested before publishing.
Was this article helpful?
Related Articles
Fix: Vercel Edge Function Not Working — Runtime APIs, Bundle Size, DB Drivers, and Middleware
How to fix Vercel Edge Function errors — Node.js APIs not available in Edge Runtime, 1MB bundle size limit, Postgres/MySQL drivers incompatible, streaming responses, geo headers, and Middleware vs Edge API Route.
Fix: Cloudflare R2 Not Working — Bindings, S3 API Auth, CORS, Presigned URLs, and r2.dev Limits
How to fix Cloudflare R2 errors — env.MY_BUCKET undefined in Workers, S3 SDK signature mismatch, multipart upload size limits, CORS on direct uploads, presigned URL generation, and r2.dev rate limits.
Fix: AWS Lambda SnapStart Not Working — Version vs Alias, Restore Hooks, and Uniqueness Bugs
How to fix Lambda SnapStart errors — feature requires published version, $LATEST not supported, restore hook for stale connections, UUID collisions after snapshot, time-based state staleness, and pricing surprises.
Fix: AWS Step Functions Not Working — ASL Syntax, Map State, Error Handling, and IAM
How to fix AWS Step Functions errors — Amazon States Language syntax, Standard vs Express workflows, Distributed Map for large datasets, Retry/Catch error handling, Lambda invoke optimization, and IAM execution role permissions.