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

ComponentPath
Upload actionpackages/features/images/actions/upload-image.action.ts
Activate imagespackages/features/images/actions/activate-session-images.action.ts
AI detectionpackages/features/listing-image-analyses-ai/
Publish listingpackages/features/listing-form/actions/publish-listing.action.ts
Create draftpackages/features/listing-form/actions/create-draft-listing.action.ts
useListingImages hookpackages/features/listing-form/components/1-add-photos/hooks/use-listing-images.ts
useItemImageUpload hookpackages/features/images/hooks/use-item-image-upload.ts
listingImages schemapackages/db/schema/listing-images.ts
listingImageAnalyses schemapackages/db/schema/listing-image-analyses.ts (DB schema, not the feature package)
Cloudflare loaderpackages/features/images/utils/cloudflare-loader.ts
Image src builderpackages/features/images/utils/image-src-builder.ts
LQIP generationpackages/features/images/utils/generate-lqip.ts
Listing formpackages/features/listing-form/views/listing-form.tsx
New item pageapps/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:

  1. Auth checkgetSession() in parallel with createClient()
  2. Validation — file type must be image/*, max 10MB
  3. Path generationitems/{itemId}/{timestamp}-{random}.{ext}
  4. Parallel processing — Sharp metadata extraction and Supabase upload run concurrently
  5. EXIF handling — orientation 5-8 (phone rotations) swap width/height to match display
  6. DB record — inserts listingImages row with status: "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:

  1. Cache check — if listingImageAnalyses row exists with candidate arrays in rawAnalysis, return instantly
  2. Compress — Sharp resizes to 768px, JPEG quality 70%
  3. Vision API — Gemini analyzes brand, colors, materials, size, condition, style tags, shot type, content safety
  4. Embeddings — generate vectors for semantic matching
  5. DB matching — match extracted attributes to existing brands, colors, materials, sizes
  6. Cache — store candidate arrays (brand_candidates, color_candidates, etc.) in rawAnalysis JSONB

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:

  1. Update itemstatus: "draft""available", save all form fields
  2. activateSessionImagesAction — the image finalization step:
    • Delete removed images (removeIds)
    • Update each image: position, rotation, crop coordinates, zoom, status → "active"
    • Set items.imageUrl to the first image's URL (cover)
  3. LQIP generation — 20px WebP blur placeholder + dominant color, saved to DB
  4. 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

StatusMeaningVisibility
pendingUploaded during form sessionOnly the seller (via RLS)
activeForm submitted, item publishedPublic (via RLS)
AbandonedDraft never submittedCleaned by cron (cascade delete)

6. Storage and CDN

Supabase Storage

  • Bucket: item-images (configured via APP_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=auto

The 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> component
  • getOptimizedImageUrl() — builds Cloudflare URLs directly for raw <img> tags / srcset
  • getEffectiveDimensions() — 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), createdAt

listingImageAnalyses

id, listingId, imageId (unique), userId,
rawAnalysis (jsonb), caption,
captionEmbedding (1536d), captionEmbeddingGemini (512d),
aiModel, analyzedAt

rawAnalysis 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.