storage — uploads that earn trust
Stage: Phase 7 — Backend - Reads: design/BRIEF.md, design/SYSTEM.md (§color, §motion for dropzone states) - Writes: components/upload/*, upload route handler or actions, lib/storage.ts, next.config.ts images.remotePatterns
Standard
- Client validation is instant: type and size rejected before a single byte leaves the browser. The server re-validates everything — the client is UX, never security.
- Every upload surface designs all six states: idle, drag-over, uploading with a real percentage, success with preview, error with retry, disabled. A spinner with no percent is not progress.
- Storage keys are server-generated; the user's filename is display metadata, never a path.
- Every stored image renders through next/image with dimensions persisted at upload time — zero CLS from user content.
- Drag-drop is an enhancement over a real
<input type="file"> — the keyboard path is the input, always.
Process
- Read design/BRIEF.md: what gets uploaded, by whom, how big. Pick server-relay vs client-direct and the store (below).
- Build the authorizing surface first: auth check, zod schema, scoped token/presign or receiving action.
- Build the dropzone per Upload UX — the input first, drag-drop and progress layered on.
- Persist the file record with the blob write; wire rendering through next/image +
remotePatterns.
- Verify empirically: upload a valid file, an oversized file, and a wrong-type file renamed to
.png — the last two must fail with designed errors on BOTH client and server paths; confirm the rendered image lands with zero CLS.
Choose the path
Two architectures, picked by file size and hosting:
- Server relay — file posts to a route handler or server action; the server writes to the store. Simplest wiring, but the file transits your function and serverless platforms cap request bodies (Vercel functions ~4.5 MB — verify the current limit before relying on it). Right for avatars and small documents.
- Client-direct — the browser uploads straight to storage; your server only authenticates and issues a scoped token or presigned URL pinning allowed type, max size, and key prefix. No body cap, no double transfer, real progress. Default for anything beyond tiny files.
Store choice:
- Vercel Blob — the default on this stack: zero infra, public/private access, a built-in client-direct token flow. Exact SDK surface (put/upload/token handler) — verify against current docs first.
- S3-compatible (AWS S3, Cloudflare R2) — when hosting off Vercel, files run large, or egress cost matters; presigned-PUT URLs give the same client-direct shape.
- Either way: the authorizing endpoint checks auth BEFORE issuing anything, and the token constrains content type, size, and prefix — an unscoped upload token is an open bucket.
Upload UX
- Dropzone = styled
<label> around the real input (sr-only, not display:none — it must stay focusable); focus-visible ring from the token palette via focus-within; drag-over shifts border to accent and background one neutral step in 150–250ms — no scale jumps, no bounce.
- Real progress requires
XMLHttpRequest — xhr.upload.onprogress gives loaded/total; fetch cannot report upload progress. Render a determinate bar + percent text; mirror to aria-live="polite" at coarse steps (25/50/75/done), not every tick.
- Validate before the network: extension AND MIME allowlist, size cap with an honest message ("Max 4 MB — this file is 12.8 MB"), image preview via
URL.createObjectURL (revoke it after).
- Multiple files: one row per file with independent progress, error, retry, and remove — one bad file never fails the batch.
- Errors land beside the dropzone, tied via
aria-describedby; retry keeps the file selected.
Server validation — zod v4
// the token-issuing handler (client-direct) or the receiving action (server relay)
import { z } from 'zod'
const uploadMeta = z.object({
type: z.enum(['image/jpeg', 'image/png', 'image/webp'], { error: 'JPEG, PNG, or WebP only' }),
size: z.number().int().positive().max(4 * 1024 * 1024, { error: 'Max 4 MB' }),
name: z.string().max(200), // display only — never a storage path
})
- Key shape:
${prefix}/${crypto.randomUUID()}.${extFromValidatedMime} — extension derived from the validated MIME, never from name (path traversal, collisions).
- Re-serving user files publicly: sniff magic bytes rather than trusting the declared content-type — a renamed
.html "image" served inline is stored XSS.
- Persist the record alongside the blob write (per
database): url, width, height, alt, size, owner. Read image dimensions server-side at write time so rendering never guesses.
- Content images require alt text at upload; decorative ones store
alt: '' explicitly.
After upload — next/image
// next.config.ts
images: { remotePatterns: [{ protocol: 'https', hostname: '<your-store-host>' }] }
- Render with stored dimensions:
<Image src={url} width={width} height={height} alt={alt} sizes="(min-width: 768px) 33vw, 100vw" /> — or fill + sizes inside a sized container.
- LCP-critical uploaded images (cover, profile hero) get
preload — priority is deprecated in Next 16.
placeholder="blur" on remote images needs a stored blurDataURL — generate the tiny base64 at upload time or omit the prop; a plain token-colored background beats a broken blur.
- Optimistic preview: show the local object URL immediately while uploading, swap to the stored URL on success, snap back with a visible error on failure — never silently.
Anti-patterns
accept="image/*" as the only validation — it filters the picker dialog, nothing else.
put(file.name or any storage key built from the user's filename — greppable; traversal and collisions.
setInterval driving a progress bar — fake progress; wire xhr.upload.onprogress or show indeterminate honestly.
<img src= for stored images — bypasses the next/image pipeline: no sizing, no format negotiation, CLS.
priority on next/image — deprecated in 16; use preload.
- Size cap only on the client — the server schema is the real cap.
fs.writeFile into public/ at runtime — serverless filesystems are ephemeral; files vanish on the next deploy.
- An
onClick dropzone div with no <input type="file"> — keyboard and screen-reader users locked out.
- Submit with no pending state — double submits create orphaned blobs.
Worked example — Loop & Thread, customer review photos as social proof
Moved to references/example.md — read only when this build's case is genuinely ambiguous; the sections above are the decision material.
Composes with
Moved to references/composes.md — the handoff map; load it when orchestrating this skill against its neighbors.
1---2name: storage3description: File upload and blob storage for a Next.js 16 site — dropzone UX with drag-drop as enhancement over a real file input, true progress from XHR upload events, instant client validation of type and size, zod v4 re-validation on the server, Vercel Blob as the default store with S3-compatible (S3/R2) as the alternative, server-generated storage keys, and post-upload rendering through next/image with persisted dimensions. Invoke during the backend phase when the brief needs avatars, user images, attachments, or any file upload, when choosing between server-relay and client-direct upload paths, or when uploaded images cause layout shift or bypass the image pipeline. Trigger phrases — "upload a file", "image upload", "avatar", "drag and drop", "file storage", "Vercel Blob", "S3 bucket", "attachments", "the upload shows no progress".4---56# storage — uploads that earn trust78**Stage:** Phase 7 — Backend - **Reads:** design/BRIEF.md, design/SYSTEM.md (§color, §motion for dropzone states) - **Writes:** components/upload/*, upload route handler or actions, lib/storage.ts, next.config.ts images.remotePatterns910## Standard1112- Client validation is instant: type and size rejected before a single byte leaves the browser. The server re-validates everything — the client is UX, never security.13- Every upload surface designs all six states: idle, drag-over, uploading with a real percentage, success with preview, error with retry, disabled. A spinner with no percent is not progress.14- Storage keys are server-generated; the user's filename is display metadata, never a path.15- Every stored image renders through next/image with dimensions persisted at upload time — zero CLS from user content.16- Drag-drop is an enhancement over a real `<input type="file">` — the keyboard path is the input, always.1718## Process19201. Read design/BRIEF.md: what gets uploaded, by whom, how big. Pick server-relay vs client-direct and the store (below).212. Build the authorizing surface first: auth check, zod schema, scoped token/presign or receiving action.223. Build the dropzone per Upload UX — the input first, drag-drop and progress layered on.234. Persist the file record with the blob write; wire rendering through next/image + `remotePatterns`.245. Verify empirically: upload a valid file, an oversized file, and a wrong-type file renamed to `.png` — the last two must fail with designed errors on BOTH client and server paths; confirm the rendered image lands with zero CLS.2526## Choose the path2728Two architectures, picked by file size and hosting:29301. **Server relay** — file posts to a route handler or server action; the server writes to the store. Simplest wiring, but the file transits your function and serverless platforms cap request bodies (Vercel functions ~4.5 MB — verify the current limit before relying on it). Right for avatars and small documents.312. **Client-direct** — the browser uploads straight to storage; your server only authenticates and issues a scoped token or presigned URL pinning allowed type, max size, and key prefix. No body cap, no double transfer, real progress. Default for anything beyond tiny files.3233Store choice:3435- **Vercel Blob** — the default on this stack: zero infra, public/private access, a built-in client-direct token flow. Exact SDK surface (put/upload/token handler) — verify against current docs first.36- **S3-compatible** (AWS S3, Cloudflare R2) — when hosting off Vercel, files run large, or egress cost matters; presigned-PUT URLs give the same client-direct shape.37- Either way: the authorizing endpoint checks auth BEFORE issuing anything, and the token constrains content type, size, and prefix — an unscoped upload token is an open bucket.3839## Upload UX4041- Dropzone = styled `<label>` around the real input (`sr-only`, not `display:none` — it must stay focusable); `focus-visible` ring from the token palette via `focus-within`; drag-over shifts border to accent and background one neutral step in 150–250ms — no scale jumps, no bounce.42- Real progress requires `XMLHttpRequest` — `xhr.upload.onprogress` gives loaded/total; `fetch` cannot report upload progress. Render a determinate bar + percent text; mirror to `aria-live="polite"` at coarse steps (25/50/75/done), not every tick.43- Validate before the network: extension AND MIME allowlist, size cap with an honest message ("Max 4 MB — this file is 12.8 MB"), image preview via `URL.createObjectURL` (revoke it after).44- Multiple files: one row per file with independent progress, error, retry, and remove — one bad file never fails the batch.45- Errors land beside the dropzone, tied via `aria-describedby`; retry keeps the file selected.4647## Server validation — zod v44849```ts50// the token-issuing handler (client-direct) or the receiving action (server relay)51import { z } from 'zod'5253const uploadMeta = z.object({54 type: z.enum(['image/jpeg', 'image/png', 'image/webp'], { error: 'JPEG, PNG, or WebP only' }),55 size: z.number().int().positive().max(4 * 1024 * 1024, { error: 'Max 4 MB' }),56 name: z.string().max(200), // display only — never a storage path57})58```5960- Key shape: `${prefix}/${crypto.randomUUID()}.${extFromValidatedMime}` — extension derived from the validated MIME, never from `name` (path traversal, collisions).61- Re-serving user files publicly: sniff magic bytes rather than trusting the declared content-type — a renamed `.html` "image" served inline is stored XSS.62- Persist the record alongside the blob write (per `database`): url, width, height, alt, size, owner. Read image dimensions server-side at write time so rendering never guesses.63- Content images require alt text at upload; decorative ones store `alt: ''` explicitly.6465## After upload — next/image6667```ts68// next.config.ts69images: { remotePatterns: [{ protocol: 'https', hostname: '<your-store-host>' }] }70```7172- Render with stored dimensions: `<Image src={url} width={width} height={height} alt={alt} sizes="(min-width: 768px) 33vw, 100vw" />` — or `fill` + `sizes` inside a sized container.73- LCP-critical uploaded images (cover, profile hero) get `preload` — `priority` is deprecated in Next 16.74- `placeholder="blur"` on remote images needs a stored `blurDataURL` — generate the tiny base64 at upload time or omit the prop; a plain token-colored background beats a broken blur.75- Optimistic preview: show the local object URL immediately while uploading, swap to the stored URL on success, snap back with a visible error on failure — never silently.7677## Anti-patterns7879- `accept="image/*"` as the only validation — it filters the picker dialog, nothing else.80- `put(file.name` or any storage key built from the user's filename — greppable; traversal and collisions.81- `setInterval` driving a progress bar — fake progress; wire `xhr.upload.onprogress` or show indeterminate honestly.82- `<img src=` for stored images — bypasses the next/image pipeline: no sizing, no format negotiation, CLS.83- `priority` on next/image — deprecated in 16; use `preload`.84- Size cap only on the client — the server schema is the real cap.85- `fs.writeFile` into `public/` at runtime — serverless filesystems are ephemeral; files vanish on the next deploy.86- An `onClick` dropzone `div` with no `<input type="file">` — keyboard and screen-reader users locked out.87- Submit with no pending state — double submits create orphaned blobs.8889## Worked example — Loop & Thread, customer review photos as social proof9091Moved to `references/example.md` — read only when this build's case is genuinely ambiguous; the sections above are the decision material.9293## Composes with9495Moved to `references/composes.md` — the handoff map; load it when orchestrating this skill against its neighbors.