You add onUploadCompleted to handleUpload(). The upload succeeds. The
callback never runs. Your terminal shows:
onUploadCompleted provided but no callbackUrl could be determined.
Nothing is broken. It is working as designed.
Why
onUploadCompleted is not called by the browser. It is called by Vercel
Blob's servers, over the internet, after the bytes land. The SDK builds the
callback URL when it issues the client token:
VERCEL_BLOB_CALLBACK_URLif set.- Otherwise, only when running on Vercel (
VERCEL=1):VERCEL_BRANCH_URLorVERCEL_URLon preview,VERCEL_PROJECT_PRODUCTION_URLon production.
On your laptop none of those exist, so there is no URL, and the SDK skips the
callback. Even if it had one, Vercel cannot reach localhost:3000.
Test it locally with a tunnel
ngrok http 3000
# .env.local
VERCEL_BLOB_CALLBACK_URL=https://abc123.ngrok-free.app
Restart the dev server. The SDK appends your route's path, so the callback
goes to https://abc123.ngrok-free.app/api/upload. Cloudflare Tunnel works
the same way.
Never set VERCEL_BLOB_CALLBACK_URL in your Vercel environment. On Vercel the
SDK already knows the right URL, and a stale tunnel URL there silently drops
every callback.
Design so it does not matter
The callback is a second path, not the only one:
- The browser gets the result directly.
upload()resolves with the blob'spathnameandurl. Save the key from there in a server action. That works everywhere, localhost included. - The callback is the backstop. If the tab closes after the upload but before the save, only the callback knows the file exists. Use it to mark the row stored, or to start processing.
Both may run. Make the callback idempotent: an upsert on the key, not an insert. A non-2xx answer makes Vercel retry it.
Verifying the callback
handleUpload() checks the x-vercel-signature header against your
BLOB_READ_WRITE_TOKEN before it calls onUploadCompleted. Two consequences:
- Do not put your normal session or origin check in front of it. The caller is
Vercel. It has no cookie and sends no
Origin. - Trust
tokenPayload(your server signed it into the token), not anything the browser sent after the fact.