Headline: A Vercel Blob client upload sends the file directly from the browser to blob storage and never routes the bytes through your Next.js function, which is why it handles multi-gigabyte files that a route handler cannot. The cost is that your database row is written by a server-to-server callback named
onUploadCompleted, and that callback never reacheshttp://localhost:3000.
I moved a document-upload feature off a plain route handler and onto Vercel Blob client uploads this month. The client-side code took twenty minutes. The next two days went to a callback that worked in production and silently did nothing on my machine, to a 409 I did not expect, and to a small pile of blobs nobody had a database row for. These are the notes I wish I had started with.
Key takeaways
- Vercel Blob is Vercel's object storage service. The
@vercel/blobpackage exposesput(),head(),list(),copy()anddel()for server code, and a separate@vercel/blob/cliententry point for browser uploads. - A client upload is a three-hop handshake: the browser asks your route handler for a one-time token, the browser sends the bytes directly to blob storage, then blob storage calls your route handler back at
onUploadCompleted. -
onUploadCompletedis an inbound HTTP request from Vercel's infrastructure to your public deployment URL, so it never arrives on localhost. Without a tunnel the upload succeeds and the database row is never written. - Since
@vercel/blobv1 theaddRandomSuffixoption defaults tofalse, so uploading the same pathname twice fails with HTTP 409 unless you passallowOverwrite: true. - The
allowedContentTypesoption validates the MIME type the browser declares, and the browser derives that from the file extension. Verify the file's magic bytes on the server before you trust it.
Why not just POST the file to a route handler?
A route handler upload is the right choice for small files and the wrong choice for large ones, because every byte travels through a serverless function you are billed for and that has a wall-clock limit. Vercel Functions now accept request bodies up to 100 MB, up from the old 4.5 MB ceiling, so proxying moderately sized files is genuinely viable in 2026 — it just does not scale past that.
If you do proxy, pass request.body straight into put(). Calling await request.formData() buffers the entire file in the function's memory before you have written a single byte to storage, which is how a 90 MB PDF turns into an out-of-memory error.
// app/api/upload-proxy/route.ts — fine for small files, wrong for large ones
import { put } from '@vercel/blob';
export async function POST(request: Request) {
const filename = new URL(request.url).searchParams.get('filename');
if (!filename || !request.body) return new Response('Bad request', { status: 400 });
// Passing request.body straight through avoids buffering the file in memory.
const blob = await put(filename, request.body, {
access: 'public',
addRandomSuffix: true,
});
return Response.json(blob);
}
| Concern | Route handler (server proxy) | Client upload (@vercel/blob/client) |
|---|---|---|
| Practical file size ceiling | 100 MB, the Vercel Functions request-body limit | Multi-gigabyte; bounded by the blob store, not the function |
| Function compute cost | Billed for the full duration of the transfer | Two short calls: issuing the token and handling the callback |
| Authentication | Simple — session cookies are on the request | Only on hop 1, inside onBeforeGenerateToken
|
| Progress reporting | Requires your own streaming plumbing | Built in via onUploadProgress
|
| Works end to end on localhost | Yes | No — the completion callback needs a public URL |
How does a Vercel Blob client upload actually work?
A Vercel Blob client upload runs in three hops, and understanding which hop you are in explains almost every bug you will hit. Hop 1 is the browser POSTing to your route handler to request a scoped upload token. Hop 2 is the browser sending the file bytes directly to Vercel Blob. Hop 3 is Vercel Blob POSTing back to that same route handler to tell you the upload finished.
'use client';
import { upload } from '@vercel/blob/client';
async function handleFile(file: File) {
const blob = await upload(file.name, file, {
access: 'public',
handleUploadUrl: '/api/upload',
clientPayload: JSON.stringify({ folder: 'invoices' }),
multipart: file.size > 100 * 1024 * 1024,
onUploadProgress: ({ percentage }) => setProgress(percentage),
});
return blob.url; // already served from the CDN
}
The server side is a single route handler wrapping handleUpload, which dispatches hop 1 and hop 3 for you based on the request body it receives.
// app/api/upload/route.ts
import { handleUpload, type HandleUploadBody } from '@vercel/blob/client';
import { auth } from '@/lib/auth';
export async function POST(request: Request): Promise<Response> {
const body = (await request.json()) as HandleUploadBody;
try {
const json = await handleUpload({
body,
request,
// Hop 1: the browser's cookies are present here. This is your only auth gate.
onBeforeGenerateToken: async (pathname, clientPayload) => {
const session = await auth();
if (!session) throw new Error('Unauthorized');
if (!pathname.startsWith(`u/${session.user.id}/`)) throw new Error('Forbidden pathname');
return {
allowedContentTypes: ['image/png', 'image/jpeg', 'application/pdf'],
maximumSizeInBytes: 25 * 1024 * 1024,
addRandomSuffix: true,
tokenPayload: JSON.stringify({ userId: session.user.id, clientPayload }),
};
},
// Hop 3: server-to-server. No cookies, no session — only tokenPayload.
onUploadCompleted: async ({ blob, tokenPayload }) => {
const { userId } = JSON.parse(tokenPayload!);
await db.insert(files).values({ userId, url: blob.url, pathname: blob.pathname });
},
});
return Response.json(json);
} catch (error) {
return Response.json({ error: (error as Error).message }, { status: 400 });
}
}
The single most important detail is that onUploadCompleted has no session. It is called by Vercel's infrastructure, not by your user's browser, so there are no cookies and no headers you control. Anything hop 3 needs to know must be serialized into tokenPayload during hop 1. I lost an hour calling auth() inside onUploadCompleted and getting null every time.
Why doesn't onUploadCompleted fire on localhost?
onUploadCompleted never fires on localhost because it is an inbound HTTP request from Vercel Blob to your application's public URL, and http://localhost:3000 is not routable from the public internet. The failure mode is deceptive: upload() resolves successfully, the file is genuinely in the blob store, the UI shows a green checkmark — and your database table stays empty forever.
The fix is to run your dev server behind a public tunnel and load the app through the tunnel hostname, not through localhost. handleUpload derives the callback URL from the incoming request, so merely exposing the port is not enough; the browser has to be on the tunnel origin when it starts the upload.
# Terminal 1
npm run dev
# Terminal 2 — then open the printed https URL, not localhost:3000
ngrok http 3000
Do not paper over this with a client-side confirmation call. Having the browser POST to a second endpoint after upload() resolves looks like it works, but it is best-effort by construction: if the tab closes in that gap the row is lost, and you now have two code paths that can write the same row.
How do I stop two users from overwriting each other's files?
In Vercel Blob the pathname is the object's identity, so two uploads with the same pathname collide. Since @vercel/blob v1 the addRandomSuffix option defaults to false, which means a second upload of invoice.pdf returns HTTP 409 instead of quietly replacing the first one. That default is the safe one, and it caught a bug for me on day one.
You have two coherent strategies. Set addRandomSuffix: true and let Vercel append a random token to every pathname, which makes collisions impossible but means re-uploading the same file creates a second object. Or build a deterministic namespaced pathname such as u/{userId}/{documentId}/{filename} and pass allowOverwrite: true, which makes re-upload idempotent.
The trap in the second strategy is that the client chooses the pathname. Combining a user-supplied pathname with allowOverwrite: true lets one account clobber another account's file. Validate the prefix inside onBeforeGenerateToken against the authenticated session, as in the handler above, and reject anything outside the caller's namespace.
How do I validate file type when the browser can lie?
The allowedContentTypes option checks the Content-Type the browser declares, and on most platforms the browser derives that value from the file extension. Renaming payload.exe to avatar.png is enough to make Chrome report image/png, so allowedContentTypes is a usability guard, not a security control.
Real validation happens in onUploadCompleted, after the bytes exist. Fetch the first few bytes with a Range header, compare against the format's magic number, and del() the blob if it does not match.
import { del } from '@vercel/blob';
const PNG_MAGIC = Buffer.from([0x89, 0x50, 0x4e, 0x47]);
onUploadCompleted: async ({ blob }) => {
// Read only the header; never download a whole file to check four bytes.
const res = await fetch(blob.url, { headers: { Range: 'bytes=0-7' } });
const head = Buffer.from(await res.arrayBuffer());
if (!head.subarray(0, 4).equals(PNG_MAGIC)) {
await del(blob.url);
return;
}
await db.insert(files).values({ url: blob.url, pathname: blob.pathname });
}
One related correction I had to make to my own mental model: a blob created with access: 'public' is world-readable to anyone holding the URL, and an unguessable URL is obscurity rather than access control. Vercel Blob supports private storage, so anything that must stay restricted should be uploaded with private access and served through a route handler that checks the session.
What happens to the blob when the database write fails?
Nothing happens to the blob — it stays in the store and you keep paying for it. The blob store and your database are two independent systems with no shared transaction, so a failure inside onUploadCompleted leaves an object no row points to. Over months of retries and timeouts that set grows quietly.
I settled on two-phase bookkeeping. During hop 1, insert a row with status pending keyed by the pathname you are about to authorize. During hop 3, flip it to ready. Then run a scheduled sweeper that pages through the store with list() and deletes anything older than a day that no row claims.
import { list, del } from '@vercel/blob';
const cutoff = Date.now() - 24 * 60 * 60 * 1000;
let cursor: string | undefined;
do {
const page = await list({ cursor, limit: 1000 });
const known = await knownPathnames(page.blobs.map((b) => b.pathname));
const orphans = page.blobs.filter(
(b) => !known.has(b.pathname) && b.uploadedAt.getTime() < cutoff,
);
if (orphans.length) await del(orphans.map((b) => b.url));
cursor = page.cursor;
} while (cursor);
The age cutoff matters. Without it the sweeper will delete a blob whose onUploadCompleted is still in flight, which is a far worse bug than the leak it was meant to fix.
How do I show real upload progress for a very large file?
The upload() and put() functions both accept an onUploadProgress callback that receives { loaded, total, percentage }, so a real progress bar needs no custom streaming code. Setting multipart: true splits the file into chunks uploaded in parallel with per-chunk retry, which is what makes a large upload survive a flaky connection.
Two behaviours surprised me. With multipart enabled, progress advances in visible steps as parts complete rather than moving smoothly, so a naive animated bar looks broken. And multipart adds request overhead per chunk, which is pure waste on a 2 MB avatar — I gate it on file size rather than enabling it globally. If you need to let users cancel, upload() accepts an abortSignal, and aborting mid-flight means onUploadCompleted simply never runs, which your reconciliation job already handles.
FAQ
Q: Do I still need a route handler if I use client uploads?
A: Yes. handleUpload lives in a route handler that the browser calls to obtain a one-time token and that Vercel Blob calls back when the upload finishes. Only the file bytes bypass it.
Q: Is a public Vercel Blob URL secure because it is unguessable?
A: No. A blob created with access: 'public' is readable by anyone who has the URL and is cached by the CDN. Use Vercel Blob's private storage plus a session-checking download route for anything sensitive.
Q: When should I turn on multipart: true?
A: When a single failed transfer would be expensive to retry — roughly files of 100 MB and up. Multipart adds one request per chunk, so it is not worth the overhead for small images.
Q: What happens if the user closes the tab mid-upload?
A: The transfer dies and onUploadCompleted never runs, so no database row is created. Your reconciliation sweep is what removes the partial artefacts, which is one more reason to build it early.
Q: Can I rename or move a blob after it is uploaded?
A: Not in place. Use copy() from @vercel/blob to write the object to a new pathname and then del() the original. Because pathname is the blob's identity, choose a naming scheme before you have a million objects.
Originally published on devya.dev. Also on eng-ahmed.com. Built by Devya Solutions.
Top comments (1)
The pending/ready two-phase row plus the age-gated sweeper is the right shape for this, the age cutoff detail at the end is the part people usually skip and then wonder why their cleanup job is racing a slow upload. The tab-close case is the one I'd worry about most on the client side, since from the user's perspective the file "uploaded" (progress hit 100, maybe even a toast fired) but there's no row for it, so a refresh just makes it vanish with no error anywhere. Do you show any optimistic UI before onUploadCompleted actually lands, or do you hold the "done" state until you get a separate signal that the row was written?