Written as a private engineering note, 24 May 2026, after debugging image uploads in a Next.js comment system. Republished here, lightly edited for a reader.
Wiring @vercel/blob into a comment system so readers could attach images looked like a thirty-minute job. It took several deploy iterations and produced four distinct bugs, one of which is invisible unless you happen to be testing from the wrong side of a national firewall.
The documented happy path is fine. What follows is everything the happy path does not tell you.
The decisions, and why#
| Decision | What I landed on | Reason |
|---|---|---|
| Upload direction | Server-side put(), never @vercel/blob/client.upload() |
I access the live site from mainland China, where vercel.com is blocked. Client-side upload() PUTs directly to vercel.com/api/blob/... and silently hangs. |
| Blob store access type | Public, set at store creation | Comment and blog images render inline via <img> / next/image; private blobs need a signed URL on every read. |
onUploadCompleted callback |
Do not register unless you genuinely need it | In @vercel/blob v2.x, registering it makes the storage API wait for the webhook to return 2xx before responding to the client. Any failure in the callback hangs the client upload. |
| Max file size | 4 MB | Vercel serverless functions cap the request body at ~4.5 MB including multipart overhead. |
| Filename handling | Sanitize to ASCII + addRandomSuffix: true |
User filenames arrive with non-ASCII characters, emoji, spaces, or path-traversal characters. |
next.config.mjs image host |
Add the public blob host pattern | next/image rejects un-allowlisted hosts; without this, images render blank. |
Bug 1: the upload that hangs, but only for some users#
@vercel/blob offers two upload patterns. They look interchangeable in the docs and have very different network footprints and failure modes.
Pattern A — client-side upload()#
browser ──POST /api/upload──▶ your function (gets short-lived clientToken)
browser ──PUT vercel.com/api/blob/?pathname=...──▶ Vercel storage API
The browser talks to vercel.com directly. That host is intermittently DNS-poisoned or RST'd from mainland China, while *.vercel.app stays reachable. The PUT fails with TypeError: Failed to fetch in roughly 300–500 ms. The SDK then retries with exponential backoff — five retries, about 30 seconds total — before giving up, so what the user sees is an upload button stuck on Uploading… apparently forever.
This is a nasty class of bug because it is invisible in every normal test. It does not reproduce in CI, it does not reproduce for most reviewers, and the failing request is one your own code never issued.
Pattern B — server-side put()#
browser ──POST multipart /api/upload──▶ your function
│
▼
put(..., file)
│
▼
Vercel storage API
The browser only ever talks to the deployment hostname, which is reachable. The function runs inside Vercel's network, where vercel.com is reachable. Comment images sit well under the 4 MB function body cap, so this works.
If you later need files larger than 4 MB, you have to accept that some users will not be able to upload at all, or move to a different storage provider — R2, or S3 with presigned URLs.
Bug 2: the onUploadCompleted trap#
This one cost about thirty minutes of misdiagnosis, because the symptom is identical to Bug 1: an upload that never resolves.
In @vercel/blob v2.x, when you register onUploadCompleted on the server route:
handleUpload({
body, request,
onBeforeGenerateToken: async () => ({ /* ... */ }),
onUploadCompleted: async ({ blob }) => { /* anything */ },
})…the storage API waits for the webhook callback to return 2xx before responding to the client's PUT. If that callback ever fails — signature mismatch, route 404, cold-start timeout, anything — the client's upload() promise hangs forever. No error. Just silence.
Only register onUploadCompleted if you actually need to do work on completion. For the ordinary "comment carries the URL, server writes the comment row on form submit" flow, don't. Persist the URL in your separate POST /api/comments handler instead.
Bug 3: store access type is fixed at creation#
Vercel Blob stores are created with a fixed access mode, Public or Private. You cannot flip an existing store from private to public through the dashboard or the API. Discovering the wrong type after the fact means:
- Delete the old store, or keep it for legacy data and accept the disconnect.
- Create a new store with the correct access type.
- Link the new store to the project — Vercel rotates
BLOB_READ_WRITE_TOKEN,BLOB_STORE_ID,BLOB_WEBHOOK_PUBLIC_KEY. - Trigger a redeploy so the function picks up the new env vars.
git commit --allow-emptyand push is the cleanest way. - Re-run
vercel env pulllocally to refresh the dev environment.
| Use case | Access | Why |
|---|---|---|
| Inline-displayed images (avatars, comment images, blog assets) | Public | Rendered via <img src> / next/image; needs to load without auth |
| User documents, private uploads, anything requiring authorization to read | Private | Reader fetches signed URLs from your backend |
The error Vercel Blob: Cannot use public access on a private store. The store is configured with private access. (or its inverse) means the access parameter on put() / upload() does not match the store's mode. Fix the store, not the code — unless private really is what you want.
Bug 4: the blank image#
next/image rejects unknown hosts. After enabling Vercel Blob, add the public blob host to next.config.mjs:
images: {
remotePatterns: [
// ...existing patterns...
{
protocol: "https",
hostname: "*.public.blob.vercel-storage.com",
pathname: "**",
},
],
},The public blob URL looks like https://<storeId>.public.blob.vercel-storage.com/<pathname>, so the wildcard covers any store you might create.
The trap is that this does not only affect obvious next/image calls. Component-library primitives — Avatar, Media, Card and friends — often wrap next/image internally. Plain <img> tags do not, but any library component taking an src prop probably does. If a blob image renders blank and the network tab shows a 400 from /_next/image?url=..., this is almost always the cause.
(Same session, same class of problem: Google profile photos come from lh3/lh4/lh5/lh6.googleusercontent.com and also need allowlisting — use **.googleusercontent.com.)
Body size limits#
Vercel functions reject request bodies larger than about 4.5 MB, and multipart form data adds a few KB of overhead per field.
| Use case | Recommended client-side limit |
|---|---|
| Comment / blog image | 4 MB |
| User avatar (typically tiny) | 1 MB |
| Anything larger | Don't use server-side upload — use client-side, or alternative storage |
Reject oversized files on the client first with file.size > MAX_BYTES, so the user gets immediate feedback instead of a 413 round-trip. The server check is the security boundary; the client check is UX. You need both.
Env vars, and one surprise#
Creating a Vercel Blob store and linking it to a project auto-injects:
BLOB_READ_WRITE_TOKEN— used by both the client-side and server-side SDKsBLOB_STORE_ID— informational; the token already encodes itBLOB_WEBHOOK_PUBLIC_KEY— only needed byhandleUploadPresigned's callback verification
You generally do not set any of these by hand. After rotating the store, always redeploy — env var updates do not propagate to already-running functions.
The surprise: vercel env pull does not decrypt marketplace-integration env vars. Values injected by marketplace integrations — Neon Postgres, Neon Auth, Vercel Blob in some setups — come back as empty strings (DATABASE_URL=""). That is a security policy, not a bug. If local dev needs those values, copy them from the integration's dashboard manually, and leave a comment in .env.local so the next contributor knows the empties are not broken.
Sanitizing filenames#
file.name can contain anything: Chinese characters, emoji, spaces, slashes, dots, control characters, very long names. Sanitize before constructing the blob pathname:
function sanitizeFilename(name: string): string {
const lastDot = name.lastIndexOf(".");
const base = lastDot > 0 ? name.slice(0, lastDot) : name;
const ext = lastDot > 0 ? name.slice(lastDot) : "";
const safeBase = base.replace(/[^\w\-]+/g, "_").slice(0, 80) || "file";
const safeExt = ext.replace(/[^\w.]+/g, "");
return safeBase + safeExt;
}Always pair it with addRandomSuffix: true, so two users uploading image.jpg do not collide:
await put(`comments/${slug}/${sanitizeFilename(file.name)}`, file, {
access: "public",
addRandomSuffix: true,
contentType: file.type,
});Sanitize any path segment derived from a URL param too. Never trust a slug to be safe.
Gate the upload route#
Any user-facing upload endpoint has to check auth before calling put(). Otherwise you have shipped a free file host:
const { data: session } = await auth.getSession();
if (!session?.user) {
return NextResponse.json(
{ error: "Sign in required to upload images" },
{ status: 401 },
);
}If you are using handleUpload (the client-side pattern), check inside onBeforeGenerateToken and throw new Error("Sign in required …") — the SDK turns it into a 401 for you.
The shape of the working route#
import { put } from "@vercel/blob";
import { type NextRequest, NextResponse } from "next/server";
import { auth } from "@/lib/auth/server";
export const runtime = "nodejs";
export const dynamic = "force-dynamic";
const MAX_BYTES = 4 * 1024 * 1024;
const ALLOWED_CONTENT_TYPES = new Set([
"image/jpeg", "image/png", "image/webp", "image/gif",
]);
export async function POST(request: NextRequest) {
if (!process.env.BLOB_READ_WRITE_TOKEN) {
return NextResponse.json({ error: "Blob storage is not configured" }, { status: 500 });
}
const { data: session } = await auth.getSession();
if (!session?.user) {
return NextResponse.json({ error: "Sign in required" }, { status: 401 });
}
const form = await request.formData();
const file = form.get("file");
const slugRaw = form.get("slug");
// ...validate file is File, slug is non-empty string, contentType allowed, size bounded...
const pathname = `comments/${sanitizeSlug(slugRaw)}/${sanitizeFilename(file.name)}`;
try {
const blob = await put(pathname, file, {
access: "public",
addRandomSuffix: true,
contentType: file.type,
});
return NextResponse.json({
url: blob.url,
pathname: blob.pathname,
contentType: file.type,
bytes: file.size,
});
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
console.error("POST /api/upload: put threw:", message);
return NextResponse.json({ error: `Upload failed: ${message}` }, { status: 502 });
}
}And the client side, which is the whole point — no @vercel/blob/client import at all:
const form = new FormData();
form.append("file", file);
form.append("slug", slug);
const res = await fetch("/api/upload", { method: "POST", body: form });
const data = await res.json().catch(() => ({}));
if (!res.ok) {
throw new Error(data?.error ?? `Upload failed (${res.status})`);
}
// data.url is the public blob URL, ready to render or persistSymptom → cause table#
| Error / symptom | Root cause | Fix |
|---|---|---|
Upload button stuck on Uploading… indefinitely, browser logs TypeError: Failed to fetch on PUT to vercel.com/api/blob/... |
vercel.com unreachable from the user's network; SDK retrying with backoff |
Switch to server-side put() |
| Upload hangs even after the server-side rewrite | An onUploadCompleted callback that is failing |
Remove onUploadCompleted from handleUpload options if not strictly needed |
Vercel Blob: Cannot use public access on a private store (or inverse) |
access arg to put() does not match the store's access type |
Recreate the store with the correct access type; it cannot be flipped |
store_not_found from the blob API |
BLOB_READ_WRITE_TOKEN points to a deleted or wrong store |
Re-link the correct store in the Vercel dashboard, redeploy |
Image renders blank, _next/image?url=... returns 400 |
Blob host missing from next.config.mjs images.remotePatterns |
Add *.public.blob.vercel-storage.com |
Blob storage is not configured 500 from your own route |
BLOB_READ_WRITE_TOKEN env var missing |
Link a Blob store, redeploy so the function picks up the env |
413 from /api/upload |
File larger than the function body limit | Reduce the limit, or switch to client-side upload with the caveat above |
| User reports a successful upload but the image never appears | The comment row never got the blob URL — usually the form submit fired before the upload promise resolved | Disable submit while uploading === true |
Debugging workflow#
- Reproduce with DevTools → Network open, "Preserve log" enabled.
- Look at the PUT host.
vercel.com/...means the browser is doing a client-side upload — network-reachability risk.*.blob.vercel-storage.comis a presigned URL. Your own/api/uploadis the server-side pattern, which is what you want. - Check status codes. A clean failure with a JSON body is easy. A pending request that never resolves means either the function is hanging (check the platform logs) or the connection failed (look for
Failed to fetchin the console). - Read the function logs. Prefix server logs with the route name (
[upload]) so filtering is easy. Add a per-request id when investigating concurrency, and drop it once fixed. - Probe the route directly from the browser console with
fetch("/api/upload", …)to bypass UI state and confirm the route itself is healthy.
When in doubt, add temporary instrumentation, push, reproduce, then remove the instrumentation before merging. The session that produced these notes left behind roughly 500 lines of debug scaffolding that had to be ripped out afterwards. Make a discrete commit for adding debug code, so removal is a clean revert.
Anti-patterns I hit#
- Reaching for
@vercel/blob/client.upload()because it is "the documented way". Read the reachability section first. - Adding
onUploadCompleted"just in case we need it later". It is not free — it is load-bearing for the upload to resolve at all. - Picking the default access mode when creating a store without thinking about whether reads need auth. You cannot change it later.
- Trusting
vercel env pullto round-trip all env vars. Marketplace-integration values come back empty. - Sanitizing only on the server. Validate type and size on the client for UX; re-validate on the server as the trust boundary.
- Sending raw
file.nameinto the pathname. Non-ASCII and special-character filenames break URL construction in subtle ways downstream.