File Uploads in the Next.js App Router: Our Field Notes on Vercel Blob, Client Uploads, and the Callback That Never Fires on localhost
Table of Contents
About the Author

Ahmed Mahmoud
Author & Developer
Software engineer passionate about web development and user experience design.
Founder of Devya · eng-ahmed.com ↗Key takeaways
• Vercel Blob is Vercel's object storage service. The @vercel/blob package exposes put(), head(), list(), copy() and del() for server code, plus a separate @vercel/blob/client entry 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.
• onUploadCompleted is 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/blob v1 the addRandomSuffix option defaults to false, so uploading the same pathname twice fails with HTTP 409 unless you pass allowOverwrite: true.
• The allowedContentTypes option validates the MIME type the browser declares, and the browser derives that value from the file extension. Verify the file's magic bytes on the server before trusting 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 that is billed 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 simply does not scale beyond that.
When our team does proxy an upload, we pass request.body straight into put(). Calling await request.formData() buffers the entire file in the function's memory before a single byte reaches storage, which is how a 90 MB PDF becomes an out-of-memory error.
The trade-off in one line: a route handler is capped at the 100 MB request-body limit, is billed for the full transfer duration, and works end to end on localhost. A client upload handles multi-gigabyte files, bills only two short calls, gives you progress reporting for free, and cannot complete its final hop on localhost.
How does a Vercel Blob client upload actually work?
A Vercel Blob client upload runs in three hops, and knowing which hop you are in explains almost every bug in this area. 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 the same route handler to report that the upload finished.
On the client, the upload() function from @vercel/blob/client takes the pathname, the File object, and a handleUploadUrl pointing at your route. On the server, a single route handler wraps handleUpload and supplies two callbacks: onBeforeGenerateToken for hop 1 and onUploadCompleted for hop 3.
The single most important detail is that onUploadCompleted has no session. It is called by Vercel's infrastructure rather than by the 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. Calling an auth() helper inside onUploadCompleted returns 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 localhost:3000 is not routable from the public internet. The failure mode is deceptive: upload() resolves successfully, the file really is in the blob store, the UI shows a success state — and the database table stays empty forever.
The fix is to run the dev server behind a public tunnel such as ngrok and load the application through the tunnel hostname rather than localhost, because handleUpload derives the callback URL from the incoming request. We deliberately avoid the workaround of having the browser confirm the upload through a second endpoint: it is best-effort by construction, since a closed tab loses the row, and it creates two code paths that write the same record.
How do we stop users from overwriting each other's files?
In Vercel Blob the pathname is the object's identity, so two uploads sharing a pathname collide. Since @vercel/blob v1 the addRandomSuffix option defaults to false, which means a second upload of invoice.pdf returns HTTP 409 rather than silently replacing the first object. That default is the safe one.
There are two coherent strategies. Set addRandomSuffix: true and let Vercel append a random token to every pathname, which makes collisions impossible at the price of duplicate objects on re-upload. Or build a deterministic namespaced pathname such as u/{userId}/{documentId}/{filename} and pass allowOverwrite: true for idempotent re-uploads. The trap in the second strategy is that the client chooses the pathname, so combining a user-supplied pathname with allowOverwrite: true lets one account clobber another's file. Validate the prefix inside onBeforeGenerateToken against the authenticated session.
How do we 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 rather than a security control.
Real validation happens in onUploadCompleted, after the bytes exist: fetch the first few bytes with a Range header, compare them against the format's magic number, and call del() on the blob when they do not match. A ranged fetch keeps the check cheap even for very large files. A related point worth stating plainly — a blob created with access: 'public' is readable by anyone holding the URL, and an unguessable URL is obscurity, not access control. Vercel Blob supports private storage, so restricted files should use private access behind a session-checking download route.
What happens to the blob when the database write fails?
Nothing happens to the blob — it stays in the store and continues to cost money. The blob store and the database are independent systems with no shared transaction, so a failure inside onUploadCompleted leaves an object that no row points to. Across months of retries and timeouts, that orphan set grows quietly.
Our team uses two-phase bookkeeping. During hop 1 we insert a row with status pending keyed by the pathname we are about to authorize, and during hop 3 we flip it to ready. A scheduled sweeper then pages through the store with list() and a cursor, deleting anything older than a day that no row claims. The age cutoff is not optional: without it the sweeper deletes blobs whose onUploadCompleted is still in flight, which is a worse bug than the leak it was written to fix.
How do we show real progress for very large files?
Both upload() and put() accept an onUploadProgress callback that receives loaded, total and percentage, so an accurate 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 lets a large upload survive an unstable connection.
Two behaviours are worth planning for. With multipart enabled, progress advances in visible steps as parts complete rather than moving smoothly, so a naively animated bar looks broken. And multipart adds one request per chunk, which is pure overhead on a 2 MB avatar, so we gate it on file size instead of enabling it globally. For cancellation, upload() accepts an abortSignal, and aborting mid-flight simply means onUploadCompleted never runs — a case the reconciliation sweep already covers.
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 private storage plus a session-checking download route for anything sensitive.
Q: When should multipart: true be enabled?
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 the overhead is not worth it 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. The reconciliation sweep is what removes the partial artefacts, which is one more reason to build it early.
Q: Can a blob be renamed or moved after upload?
A: Not in place. Use copy() from @vercel/blob to write the object to a new pathname, then del() the original. Because pathname is the blob's identity, choose a naming scheme before you have a million objects.
Further Reading
Full-Stack Engineering
Web Push in the Next.js App Router: How We Ship Service Workers, VAPID, and iOS-Safe Permission Flows
Web Push is a browser API that delivers a server-sent notification to a device while the site is closed. We break down the three parts a Next.js App Router project needs — a service worker at the origin root, a VAPID key pair, and a Node.js-runtime route handler — plus the iOS Home Screen rule that silently blocks everything else.
Full-Stack Engineering
Content Security Policy in the Next.js App Router: Nonces, strict-dynamic, and the Static-Rendering Trade-Off
At Devya we rolled a nonce-based Content Security Policy across our Next.js App Router applications. A per-request nonce plus 'strict-dynamic' is the only script policy that survives the framework's runtime chunk loading — and it quietly converts static routes into dynamic ones. These are our field notes on the trade-off and how we scoped it.