Uploads + a records-powered image gallery
Start state: nothing. End state: users upload images to Convex storage from a drag-and-drop page, and a records-based gallery view shows them as cards. Two features, one join: the uploads feature owns storage, and the entity kit's gallery view renders any row with an imageKey.
1. Scaffold with uploads + records
pnpm new moodboard --title "Moodboard" --type webapp --features uploads,records
pnpm install
cd apps/moodboard && npx convex dev --once && pnpm exec tsc --noEmit
2. The Convex storage flow
convex/uploads.ts implements the canonical three-step flow, always scoped to the signed-in user:
- generateUploadUrl — a mutation returns a short-lived upload URL (ctx.storage.generateUploadUrl())
- the client POSTs the file bytes to that URL and gets back a storageId
- saveFile — a mutation records ownership + metadata (name, type, size) in the files table
Serving works in reverse: listFiles resolves each row's storageId to a URL at query time —
// convex/uploads.ts (shipped)
return Promise.all(
rows.map(async (row) => ({ ...row, url: await ctx.storage.getUrl(row.storageId) })),
);
and deleteFile removes both the storage object and the row, after checking the row belongs to the caller.
Never store the serve URL — store the storageId. URLs are resolved fresh by ctx.storage.getUrl on every query; a persisted URL would eventually go stale.
3. Try the Uploads page
pnpm dev
Sign up, open Uploads, drop a few images on the dashed card. Expected: an "Uploaded." toast and the files listed with name, type badge, and size. The page (src/features/uploads/pages) is the client half of the flow — for each file it awaits generateUploadUrl, fetch-POSTs the bytes, reads { storageId } from the response, then calls saveFile.
4. Wire the gallery
The records feature's CONFIG already declares imageKey: "imageUrl" — any row with a value there gets an image card in the gallery view. The simplest bridge is copy-paste: open a file on the Uploads page, copy its URL, create a record, and paste it into the Image field. Switch the Records view to gallery — your upload is now a card.
Optional: upload directly from the record form
For a real product, reuse the same three-step flow inside the record editor so users never see a URL: run generateUploadUrl → POST → getUrl, then save the result into the record's imageUrl. Because the uploads endpoints are ordinary mutations in api.uploads, any feature can call them:
const generateUploadUrl = useMutation(api.uploads.generateUploadUrl);
const url = await generateUploadUrl({});
const res = await fetch(url, { method: "POST", headers: { "Content-Type": file.type }, body: file });
const { storageId } = await res.json();
// resolve a serve URL server-side, then: update({ id, imageUrl });
5. Ship it
cd ../.. && pnpm deploy moodboard
Convex storage deploys with the backend — nothing extra to configure; uploaded files live in the same project as their metadata.
What you built
- The complete Convex storage loop: generateUploadUrl → POST → storageId → saveFile → getUrl at read time
- A per-user file manager with drag-and-drop, previews, and safe deletes
- A gallery: the entity kit's imageKey turned uploads into visual cards with zero view code