Vercel Blob

Store files on Vercel: uploads, images, documents. Public or private.

What it is

Vercel Blob is file storage for your project. put() uploads, list() and head() read metadata, get() streams a file, del() removes it. A public store gives every file a CDN URL. This lab uses a private store: files have no public URL, so they can only be read by your own code, which decides who sees what. On Vercel the SDK signs in with the deployment's short-lived OIDC token, so no long-lived storage key exists to leak.

When to use it

  • User uploads: avatars, attachments, documents
  • Private files served only to the right people
  • Generated files: exports, reports, images
Official docs ↗

1. Upload (Server Action)

Checking your session…

The server checks the file's first bytes (not its name), names it with a random id, and keeps only the newest 12 uploads. Anything bigger than 4.5 MB would need a client upload, straight from the browser to Blob.

2. Private gallery

Each image is streamed by a route handler that checks your session first. Try opening one in a private window: 401.

Listing the store…

Show code · src/app/blob/actions.ts
"use server";

import { del, list, put } from "@vercel/blob";
import { refresh } from "next/cache";
import { currentSession } from "@/lib/current-session";
import { checkLimit, retryIn, writeLimit } from "@/lib/redis";
import { KEEP_UPLOADS, UPLOAD_PREFIX, blobConfigured, checkUpload, uploadPathname } from "@/lib/uploads";

export type UploadState = { message: string; error: boolean } | null;

// Server upload: the file goes browser → this function → Vercel Blob. On
// Vercel the SDK authenticates with the deployment's OIDC token, so there is
// no long-lived Blob token anywhere.
export async function uploadImage(_prev: UploadState, formData: FormData): Promise<UploadState> {
  if (!blobConfigured()) return { message: "Blob isn't connected for this environment.", error: true };
  const session = await currentSession();
  if (!session) return { message: "Sign in first (on the Proxy page): uploads need a lab session.", error: true };

  const check = await checkUpload(formData.get("file"));
  if (!check.ok) return { message: check.error, error: true };

  const limit = await checkLimit(writeLimit);
  if (limit && !limit.success) return { message: `Slow down: try again in ${retryIn(limit.reset)} s.`, error: true };

  const blob = await put(uploadPathname(check.kind.ext), Buffer.from(check.bytes), {
    access: "private",
    contentType: check.kind.type,
  });

  // Keep only the newest uploads, so the store can't grow without bound.
  const { blobs } = await list({ prefix: UPLOAD_PREFIX, limit: 100 });
  const old = blobs.sort((a, b) => +b.uploadedAt - +a.uploadedAt).slice(KEEP_UPLOADS).map((b) => b.pathname);
  if (old.length) await del(old);

  refresh();
  return { message: `Uploaded ${blob.pathname} (${Math.round(check.bytes.length / 1024)} KB, ${check.kind.type}).`, error: false };
}
Show code · src/app/api/blob/[name]/route.ts
import { get } from "@vercel/blob";
import { currentSession } from "@/lib/current-session";
import { UPLOAD_PREFIX, blobConfigured, isUploadName } from "@/lib/uploads";

// Serves a private blob. The store has no public URLs, so every file goes
// through this function, which decides who may see it: here, anyone with a
// lab session. This is where per-user permissions would go.
export async function GET(_request: Request, { params }: { params: Promise<{ name: string }> }) {
  const { name } = await params;
  if (!isUploadName(name)) return new Response("Not found", { status: 404 });
  if (!blobConfigured()) return new Response("Blob isn't connected", { status: 503 });
  if (!(await currentSession())) return new Response("Sign in to view uploads", { status: 401 });

  const result = await get(`${UPLOAD_PREFIX}${name}`, { access: "private" });
  if (!result || result.statusCode !== 200 || !result.stream) return new Response("Not found", { status: 404 });

  return new Response(result.stream, {
    headers: {
      "Content-Type": result.blob.contentType ?? "application/octet-stream",
      "X-Content-Type-Options": "nosniff",
      // The sandboxing Content-Security-Policy for these files is set in
      // next.config.ts, because config headers override a route's own.
      // Private: only this visitor's browser may cache it, not Vercel's CDN.
      "Cache-Control": "private, max-age=300",
    },
  });
}
Show code · src/lib/uploads.ts
// Rules for the Blob demo's uploads. Uploads are open to anyone who signs in,
// so they're locked down: images only (checked from the file's bytes, not its
// name or the browser's claimed type), a size cap, and only the newest few kept.

export const UPLOAD_PREFIX = "uploads/";
// Server Actions accept 1 MB request bodies by default, multipart overhead
// included, so files stay comfortably below that.
export const MAX_UPLOAD_BYTES = 900 * 1024;
export const KEEP_UPLOADS = 12;

const signatures = [
  { type: "image/png", ext: "png", bytes: [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a] },
  { type: "image/jpeg", ext: "jpg", bytes: [0xff, 0xd8, 0xff] },
  { type: "image/gif", ext: "gif", bytes: [0x47, 0x49, 0x46, 0x38] },
] as const;

export type ImageKind = { type: string; ext: string };

// Identifies PNG, JPEG, GIF and WebP from their magic bytes. SVG is refused
// on purpose: it's XML that can carry scripts.
export function sniffImage(bytes: Uint8Array): ImageKind | null {
  for (const s of signatures) {
    if (s.bytes.every((b, i) => bytes[i] === b)) return { type: s.type, ext: s.ext };
  }
  const ascii = (from: number, to: number) => String.fromCharCode(...bytes.subarray(from, to));
  if (bytes.length >= 12 && ascii(0, 4) === "RIFF" && ascii(8, 12) === "WEBP") return { type: "image/webp", ext: "webp" };
  return null;
}

export type UploadCheck = { ok: true; bytes: Uint8Array; kind: ImageKind } | { ok: false; error: string };

export async function checkUpload(file: FormDataEntryValue | null): Promise<UploadCheck> {
  if (!(file instanceof File) || file.size === 0) return { ok: false, error: "Choose an image first." };
  if (file.size > MAX_UPLOAD_BYTES) return { ok: false, error: `That file is ${Math.round(file.size / 1024)} KB; the limit is 900 KB.` };
  const bytes = new Uint8Array(await file.arrayBuffer());
  const kind = sniffImage(bytes);
  return kind ? { ok: true, bytes, kind } : { ok: false, error: "Only PNG, JPEG, GIF and WebP images are accepted." };
}

// Pathnames are generated by the server (a random id plus the sniffed
// extension), never taken from the visitor. The file route only serves names
// of exactly that shape, so it can't be pointed at anything else in the store.
export const uploadPathname = (ext: string) => `${UPLOAD_PREFIX}${crypto.randomUUID()}.${ext}`;
export const isUploadName = (name: string) => /^[0-9a-f-]{36}\.(png|jpg|gif|webp)$/.test(name);

export const blobConfigured = () => Boolean(process.env.BLOB_STORE_ID || process.env.BLOB_READ_WRITE_TOKEN);