Image upload architecture
How images flow from the user's device to Supabase Storage, get analyzed by AI, and are served via Cloudflare — the full draft-to-published pipeline
Overview
The image system handles upload, AI analysis, storage, and CDN delivery for item listings. It follows a draft-first pattern: a draft item is created on page load, images are uploaded against it immediately, and everything activates atomically on form submission.
User opens /items/new
↓
[SSR] createDraftListingAction() → listings row (status="draft")
↓
[Client] ListingForm mounts with the draft listing
↓
User drops/selects images
↓
[useItemImageUpload] → uploadImageAction(formData)
↓
[Cover change] → detectSingleImageAction → AI auto-fill
↓
User submits form
↓
[createItemAction] → activateSessionImagesAction → LQIP generation
↓
Item live (status="available", images status="active")Key files
| Component | Path |
|---|---|
| Upload action | packages/features/images/actions/upload-image.action.ts |
| Activate images | packages/features/images/actions/activate-session-images.action.ts |
| AI detection | packages/features/listing-image-analyses-ai/ |
| Publish listing | packages/features/listing-form/actions/publish-listing.action.ts |
| Create draft | packages/features/listing-form/actions/create-draft-listing.action.ts |
| useListingImages hook | packages/features/listing-form/components/1-add-photos/hooks/use-listing-images.ts |
| useItemImageUpload hook | packages/features/images/hooks/use-item-image-upload.ts |
| listingImages schema | packages/db/schema/listing-images.ts |
| listingImageAnalyses schema | packages/db/schema/listing-image-analyses.ts (DB schema, not the feature package) |
| Cloudflare loader | packages/features/images/utils/cloudflare-loader.ts |
| Image src builder | packages/features/images/utils/image-src-builder.ts |
| LQIP generation | packages/features/images/utils/generate-lqip.ts |
| Listing form | packages/features/listing-form/views/listing-form.tsx |
| New item page | apps/web/app/[locale]/(site)/(distraction-free)/(item-form)/items/new/page.tsx |
1. Draft creation (SSR)
When the user navigates to /items/new, the page server component calls createDraftListingAction():
const [draftListing] = await db.insert(listings).values({
name: "Draft Item",
description: "",
priceCents: 0,
sellerId: session.user.id,
status: "draft",
}).returning({ id: listings.id });The returned listing is passed to the ListingForm client component. Abandoned drafts are cleaned up by a cron job (cascade deletes images + analyses).
2. Image upload
uploadImageAction
The core server action receives a FormData with the file and itemId:
- Auth check —
getSession()in parallel withcreateClient() - Validation — file type must be
image/*, max 10MB - Path generation —
items/{itemId}/{timestamp}-{random}.{ext} - Parallel processing — Sharp metadata extraction and Supabase upload run concurrently
- EXIF handling — orientation 5-8 (phone rotations) swap width/height to match display
- DB record — inserts
listingImagesrow withstatus: "pending"
const [sharpMetadata, uploadResult] = await Promise.all([
sharp(uint8Array).metadata(),
supabase.storage.from(bucket).upload(filePath, uint8Array, { contentType: file.type })
]);Metadata stored includes: format, color space, channels, density, orientation, file size, original filename.
Client hooks
useItemImageUpload — orchestrates file upload and AI analysis:
- Phase 1: First image analyzed immediately (for form auto-fill)
- Phase 2: Remaining images analyzed in background with progress tracking
useListingImages — manages image state during the form session:
interface SessionImage {
id: string; // DB UUID
url: string; // Public storage URL
rotation: number; // 0, 90, 180, 270
crop: ImageCropData | null;
zoom: number;
isNew: boolean; // Uploaded this session
isRemoved: boolean; // Soft-deleted
}Operations: addUploadedImage(), remove(), reorder(), rotate(), setCrop(), setZoom(), getSubmitPayload().
3. AI image analysis
When the cover image changes, the form fetches it as a blob and calls detectSingleImageAction:
- Cache check — if
listingImageAnalysesrow exists with candidate arrays inrawAnalysis, return instantly - Compress — Sharp resizes to 768px, JPEG quality 70%
- Vision API — Gemini analyzes brand, colors, materials, size, condition, style tags, shot type, content safety
- Embeddings — generate vectors for semantic matching
- DB matching — match extracted attributes to existing brands, colors, materials, sizes
- Cache — store candidate arrays (
brand_candidates,color_candidates, etc.) inrawAnalysisJSONB
The form auto-fills brand, colors, materials, size, and suggested category from the analysis.
Raw analysis shape
{
"content_safety": { "rating": "safe", "reason": "..." },
"brand": { "name": "Nike", "confidence": 0.95 },
"colors": [{ "name": "blue", "hex": "#0000FF", "prominence": "primary" }],
"materials": ["cotton", "polyester"],
"condition_assessment": { "overall": "good", "defects": [] },
"size_evidence": { "visible": true, "raw_text": "M" },
"style_tags": ["casual", "minimalist"],
"shot_type": "flat_lay"
}4. Form submission and activation
When the user publishes, createItemAction runs:
- Update item —
status: "draft"→"available", save all form fields activateSessionImagesAction— the image finalization step:- Delete removed images (
removeIds) - Update each image: position, rotation, crop coordinates, zoom,
status → "active" - Set
items.imageUrlto the first image's URL (cover)
- Delete removed images (
- LQIP generation — 20px WebP blur placeholder + dominant color, saved to DB
- Content safety check
Activation payload
{
itemId: string,
images: [{
id: string,
position: number, // 0 = cover
rotation?: number, // 0, 90, 180, 270
cropX?: number, // percentages (0-100)
cropY?: number,
cropWidth?: number,
cropHeight?: number,
zoom?: number,
}],
removeIds?: string[],
}5. Image status lifecycle
| Status | Meaning | Visibility |
|---|---|---|
pending | Uploaded during form session | Only the seller (via RLS) |
active | Form submitted, item published | Public (via RLS) |
| Abandoned | Draft never submitted | Cleaned by cron (cascade delete) |
6. Storage and CDN
Supabase Storage
- Bucket:
item-images(configured viaAPP_CONFIG.STORAGE_BUCKET) - Path:
items/{itemId}/{timestamp}-{random}.{ext}
Cloudflare Image Transformations
Images are served through Cloudflare's /cdn-cgi/image/ endpoint for on-the-fly resize, crop, rotation, and format conversion. No pre-optimized variants are stored.
The custom Next.js image loader parses hash params appended to URLs:
https://...supabase.co/storage/v1/object/item-images/items/abc/img.jpg#rotate=90&crop=4:5&gravity=autoThe loader strips the hash, builds a Cloudflare URL:
https://cdn.example.com/cdn-cgi/image/width=400,quality=75,format=auto,fit=cover,rotate=90/https://...supabase.co/...Helper functions
buildImageSrc()— appends hash params (#rotate=90&crop=4:5) for the Next.js<Image>componentgetOptimizedImageUrl()— builds Cloudflare URLs directly for raw<img>tags / srcsetgetEffectiveDimensions()— swaps width/height for 90°/270° rotations
7. LQIP (blur placeholders)
Generated after form submission via Cloudflare:
- LQIP: 20px wide, quality 20%, WebP → base64 data URI stored in
listingImages.lqip - Dominant color: 1x1 resize → extract RGB → hex string
Used for blur-up effect while the full image loads.
8. Database schema
listingImages
id, listingId, url, altText, position, status,
userId, width, height, rotation,
cropX, cropY, cropWidth, cropHeight, zoom,
metadata (jsonb), lqip (base64), createdAtlistingImageAnalyses
id, listingId, imageId (unique), userId,
rawAnalysis (jsonb), caption,
captionEmbedding (1536d), captionEmbeddingGemini (512d),
aiModel, analyzedAtrawAnalysis stores all AI evidence and enrichment candidate caches:
brand,colors,materials,condition_assessment,size_evidence(Gemini evidence)category_candidates,brand_candidates,color_candidates,material_candidates,size_candidates(DB match candidates)
9. Edit mode differences
In edit mode, ListingForm receives the existing listing with its images. The useListingImages hook initializes SessionImage[] from existing DB records (with isNew: false). The same upload/reorder/rotate/crop flow applies. On submit, activateSessionImagesAction updates positions and transforms for all images (existing + new) and deletes any removed ones.