Skip to content

Fix: Vercel Blob Not Working — put/get/del, handleUpload Browser Flow, Access Modes, and Multipart

FixDevs ·

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) vs handleUpload() (client uploads directly with a short-lived token). Each has different security tradeoffs and size limits.
  • Filename hashing by default. put("cover.jpg", file) produces cover-randomhash.jpg to avoid CDN cache issues. To keep the same URL, use addRandomSuffix: 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:

  1. Storage → Create a Blob store.
  2. Connect it to your project.
  3. Vercel sets BLOB_READ_WRITE_TOKEN automatically.

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/blob

Basic 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:

  1. Client calls upload() from @vercel/blob/client.
  2. SDK sends a request to your /api/upload route.
  3. Your route validates (auth, content type) and calls handleUpload.
  4. handleUpload returns a short-lived signed URL for the browser.
  5. Browser PUTs the file directly to Vercel Blob.
  6. Vercel Blob calls back to your /api/upload with onUploadCompleted.

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 suffix

This 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, immutable is 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:

  • BlobAccessError despite token set. Token doesn’t match the project. Re-run vercel env pull after creating the blob store.
  • CORS errors on browser uploads. handleUpload handles CORS automatically. If you’re using a custom upload flow, you’ll have to manage CORS manually.
  • onUploadCompleted not called. Webhook fires from Vercel Blob → your endpoint. In local dev with next dev, the webhook can’t reach localhost. 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 required from put. First arg must be a non-empty pathname. Random pathnames: put(crypto.randomUUID() + ".jpg", file, ...).
  • TypeScript: cannot import @vercel/blob/client in server file. It’s a client-only entry. Use import { 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.

F

FixDevs

Solo developer based in Japan. Every solution is cross-referenced with official documentation and tested before publishing.

Was this article helpful?

Related Articles