# How to Build an Image Hosting with Lovable

- Tool: How to Build with Lovable
- Difficulty: Intermediate
- Compatibility: Lovable Pro or higher
- Last updated: April 2026

## TL;DR

Build an Imgur-style image hosting platform in Lovable using Supabase Storage for uploads, built-in image transformation URLs for resizing, a masonry grid layout, drag-and-drop multi-upload, album organization, and shareable public links — all without a separate CDN or image processing service.

## Before you start

- Lovable Pro account (multi-upload and album logic needs credits to generate correctly)
- Supabase project created at supabase.com — free tier includes 1GB Storage
- Supabase URL and anon key added to Cloud tab → Secrets
- Optional: Supabase Pro plan ($25/mo) if you need more than 1GB of image storage

## Step-by-step guide

### 1. Define the schema and create the Storage bucket

The images table stores metadata about each uploaded file. The storage_path column is the key that connects to the actual bytes in Supabase Storage. Albums and album_images handle grouping.

```
Create an image hosting platform with Supabase. Set up these tables:

- images: id, owner_id (references auth.users, nullable for public anonymous uploads), title (text nullable), alt_text (text nullable), slug (text unique — 8-char random alphanumeric), storage_path (text), file_size_bytes (int), width_px (int nullable), height_px (int nullable), mime_type (text), view_count (int default 0), created_at
- albums: id, owner_id (references auth.users), title, description (nullable), slug (text unique — 8-char), cover_image_id (references images nullable), visibility (public|private), created_at
- album_images: id, album_id (references albums), image_id (references images), position (int), added_at

RLS:
- images: SELECT is public for all rows (anonymous image hosting). INSERT requires authentication. UPDATE/DELETE requires owner_id = auth.uid()
- albums: SELECT is public for visibility = 'public'. All CRUD for private albums requires owner_id = auth.uid()
- album_images: SELECT inherits parent album visibility. Modify requires album owner

Create a Supabase Storage bucket called 'images':
- Public bucket (allow public URL access)
- Allowed MIME types: image/jpeg, image/png, image/gif, image/webp, image/svg+xml
- Max upload size: 10MB

Create an RPC function increment_image_views(p_image_id uuid) that increments view_count by 1.
```

> Pro tip: Ask Lovable to generate the slug column using a PostgreSQL default expression: DEFAULT substring(md5(random()::text), 1, 8). This generates a random 8-character hex string automatically on every INSERT without needing application code to create slugs.

**Expected result:** All three tables are created with RLS and slugs. The images bucket is configured in Supabase Storage. TypeScript types are generated.

### 2. Build drag-and-drop multi-upload with progress

The upload component accepts multiple files by drag-and-drop or file picker, uploads them in parallel to Supabase Storage, and inserts metadata rows for each. Progress is shown per file.

```
import { useCallback, useState } from 'react'
import { supabase } from '@/integrations/supabase/client'
import { Progress } from '@/components/ui/progress'
import { Button } from '@/components/ui/button'
import { X, Upload, ImageIcon } from 'lucide-react'

type UploadFile = { file: File; progress: number; status: 'queued' | 'uploading' | 'done' | 'error'; imageId?: string }

function randomSlug(n: number) {
  return Array.from(crypto.getRandomValues(new Uint8Array(n))).map((b) => b.toString(16).padStart(2, '0')).join('').slice(0, n)
}

export function ImageUploader({ onUploaded }: { onUploaded: (ids: string[]) => void }) {
  const [files, setFiles] = useState<UploadFile[]>([])
  const [dragging, setDragging] = useState(false)

  const addFiles = (incoming: FileList | null) => {
    if (!incoming) return
    const newFiles = Array.from(incoming)
      .filter((f) => f.type.startsWith('image/'))
      .map((file) => ({ file, progress: 0, status: 'queued' as const }))
    setFiles((prev) => [...prev, ...newFiles])
  }

  const uploadAll = async () => {
    const uploadedIds: string[] = []
    await Promise.all(
      files.filter((f) => f.status === 'queued').map(async (entry, i) => {
        const slug = randomSlug(8)
        const ext = entry.file.name.split('.').pop() ?? 'jpg'
        const path = `${slug}.${ext}`
        setFiles((prev) => prev.map((f) => f === entry ? { ...f, status: 'uploading' } : f))
        const { error } = await supabase.storage.from('images').upload(path, entry.file, {
          onUploadProgress: ({ loaded, total }) => {
            const pct = Math.round((loaded / (total ?? 1)) * 100)
            setFiles((prev) => prev.map((f) => f === entry ? { ...f, progress: pct } : f))
          },
        })
        if (error) {
          setFiles((prev) => prev.map((f) => f === entry ? { ...f, status: 'error' } : f))
          return
        }
        const { data: row } = await supabase.from('images').insert({
          slug,
          storage_path: path,
          file_size_bytes: entry.file.size,
          mime_type: entry.file.type,
          title: entry.file.name.replace(/\.[^.]+$/, ''),
        }).select('id').single()
        if (row) uploadedIds.push(row.id)
        setFiles((prev) => prev.map((f) => f === entry ? { ...f, status: 'done', imageId: row?.id } : f))
      })
    )
    onUploaded(uploadedIds)
  }

  return (
    <div className="space-y-4">
      <div
        onDragOver={(e) => { e.preventDefault(); setDragging(true) }}
        onDragLeave={() => setDragging(false)}
        onDrop={(e) => { e.preventDefault(); setDragging(false); addFiles(e.dataTransfer.files) }}
        onClick={() => document.getElementById('file-input')?.click()}
        className={`border-2 border-dashed rounded-xl p-12 text-center cursor-pointer transition-colors ${ dragging ? 'border-primary bg-primary/5' : 'border-muted-foreground/30 hover:border-primary/50'}`}
      >
        <Upload className="w-8 h-8 mx-auto mb-3 text-muted-foreground" />
        <p className="text-sm font-medium">Drop images here or click to browse</p>
        <p className="text-xs text-muted-foreground mt-1">JPEG, PNG, GIF, WebP — up to 10MB each</p>
      </div>
      <input id="file-input" type="file" multiple accept="image/*" className="hidden" onChange={(e) => addFiles(e.target.files)} />
      {files.length > 0 && (
        <div className="space-y-2">
          {files.map((f, i) => (
            <div key={i} className="flex items-center gap-3">
              <ImageIcon className="w-4 h-4 shrink-0 text-muted-foreground" />
              <div className="flex-1 min-w-0">
                <p className="text-sm truncate">{f.file.name}</p>
                {f.status === 'uploading' && <Progress value={f.progress} className="h-1 mt-1" />}
              </div>
              <span className="text-xs text-muted-foreground shrink-0">
                {f.status === 'done' ? 'Done' : f.status === 'error' ? 'Error' : f.status === 'uploading' ? `${f.progress}%` : 'Queued'}
              </span>
              {f.status !== 'uploading' && (
                <Button variant="ghost" size="icon" className="h-6 w-6" onClick={(e) => { e.stopPropagation(); setFiles((prev) => prev.filter((_, j) => j !== i)) }}>
                  <X className="h-3 w-3" />
                </Button>
              )}
            </div>
          ))}
          <Button onClick={uploadAll} className="w-full" disabled={files.every((f) => f.status !== 'queued')}>
            Upload {files.filter((f) => f.status === 'queued').length} image{files.filter((f) => f.status === 'queued').length !== 1 ? 's' : ''}
          </Button>
        </div>
      )}
    </div>
  )
}
```

**Expected result:** Dragging 5 images onto the zone shows them in the queue list. Clicking Upload uploads all in parallel with individual progress bars. Each completed image gets a row in the images table.

### 3. Build the masonry grid with transformation thumbnails

The masonry grid uses CSS columns for layout. Thumbnail URLs use Supabase's built-in image transformation to serve WebP images at a fixed width, reducing bandwidth significantly compared to serving originals.

```
Build a MasonryGallery component at src/components/gallery/MasonryGallery.tsx.

Props: images (array of { id, slug, storage_path, title, width_px, height_px, view_count })

Logic:
- For each image, construct a thumbnail URL using Supabase Storage transformation:
  const { data } = supabase.storage.from('images').getPublicUrl(storage_path, {
    transform: { width: 400, format: 'webp', quality: 80 }
  })
- Layout: CSS columns, not flexbox or grid.
  <div style={{ columnCount: columns, columnGap: '1rem' }}> where columns = 2 on mobile, 3 on tablet, 4 on desktop (use a custom hook useBreakpoint).
  Each image: <div style={{ breakInside: 'avoid', marginBottom: '1rem' }}>
- On hover, show a dark overlay with the image title and view count.
- Clicking an image opens a shadcn/ui Dialog lightbox showing:
  - Full-resolution image (not the thumbnail URL)
  - Title (editable inline for the owner)
  - Slug-based share URL: window.location.origin + '/i/' + slug with a Copy button
  - Download button (anchor tag pointing to original storage URL)
  - Embed code: <img src='...' alt='...'> in a code block with a Copy button
  - Delete button (only visible to the image owner)
- Lazy load images using loading='lazy' on the img tag.
```

> Pro tip: Ask Lovable to implement blur-up placeholders. When constructing image thumbnails, also generate a tiny 10px-wide version (transform: { width: 10, format: 'webp' }) and show it as a blurred background while the full thumbnail loads. This is the same technique used by Medium and Gatsby Image.

**Expected result:** The gallery renders images in a Pinterest-style masonry layout. Thumbnails load fast from transformation URLs. Clicking an image opens the lightbox with share and download options.

### 4. Build album management

Albums group images into shareable collections. The album creation flow lets users name the album and select images from their library to include.

```
Build album management pages.

1. Albums list page at src/pages/Albums.tsx:
- Fetch all albums where owner_id = auth.uid() ordered by created_at desc.
- Display as a grid of Cards with cover image (or grid of up to 4 thumbnails), title, image count, and visibility Badge.
- 'New Album' Button opens a Dialog:
  - title Input (required)
  - description Textarea (optional)
  - visibility Select (public/private)
  - Submit inserts into albums table.

2. Album detail page at src/pages/AlbumDetail.tsx (route: /albums/[id]):
- Fetch album metadata and all album_images joined with images, ordered by position.
- Show the album title, description, and visibility Badge.
- Render images in the MasonryGallery component.
- 'Add Images' Button opens a Sheet showing the user's uploaded images as a checkbox grid. Checking images and clicking 'Add to Album' inserts rows into album_images with position = max(position) + 10 for each new image.
- 'Share Album' Button copies window.location.origin + '/a/' + album.slug to clipboard using navigator.clipboard.writeText.
- For public albums, show a 'Make Private' Button. For private, show 'Make Public' Button.
- 'Remove' button on each image in the album removes the album_images row.
```

**Expected result:** Users can create albums, add images to them, reorder by dragging, and share the album URL. Public album URLs work for visitors who are not logged in.

### 5. Build the public image and album pages

Public-facing pages for shared links. The /i/[slug] page shows a single image with metadata. The /a/[slug] page shows the full album gallery. Both work without authentication.

```
Build two public pages:

1. Image page at src/pages/PublicImage.tsx (route: /i/[slug]):
- Fetch image by slug from the images table (no auth required — public SELECT policy).
- If not found, show a 404 state with a Button linking back to the homepage.
- On component mount, call supabase.rpc('increment_image_views', { p_image_id: image.id }).
- Layout: centered image with max-width 1200px, metadata below.
- Metadata row: file size formatted (e.g., '2.4 MB'), dimensions (e.g., '1920 x 1080'), upload date, view count.
- Share row: copy URL Button, download Button (direct link to storage), embed Button.
- Embed dialog: shows <img src='...' alt='...'> snippet in a shadcn/ui code block with one-click copy.
- 'See all uploads by this user' link to /user/[owner_id] if the image has an owner.

2. Album page at src/pages/PublicAlbum.tsx (route: /a/[slug]):
- Fetch album by slug. If visibility = 'private' and the viewer is not the owner, return a 403 state.
- Show album title, description, and image count.
- Render images using the MasonryGallery component.
- 'Share' button copies the current URL.
```

> Pro tip: Add Open Graph meta tags to both public pages so shared links render a preview image in Slack, iMessage, and Twitter. In Lovable, ask it to add a <Helmet> or meta tag component to each page that sets og:image to the transformation URL of the image (or album cover image) and og:title to the image or album title.

**Expected result:** Visiting /i/{slug} shows the image, metadata, and share options without logging in. View count increments on each visit. Embedding the provided code snippet works in any HTML page.

## Complete code example

File: `src/lib/imageTransform.ts`

```typescript
import { supabase } from '@/integrations/supabase/client'

type TransformOptions = {
  width?: number
  height?: number
  quality?: number
  format?: 'webp' | 'jpeg' | 'png'
  resize?: 'cover' | 'contain' | 'fill'
}

export function getImageUrl(storagePath: string, transform?: TransformOptions): string {
  if (!transform) {
    const { data } = supabase.storage.from('images').getPublicUrl(storagePath)
    return data.publicUrl
  }
  const { data } = supabase.storage.from('images').getPublicUrl(storagePath, { transform })
  return data.publicUrl
}

export function getThumbnailUrl(storagePath: string, size: 'small' | 'medium' | 'large' = 'medium'): string {
  const sizes = {
    small: { width: 150, quality: 70, format: 'webp' as const },
    medium: { width: 400, quality: 80, format: 'webp' as const },
    large: { width: 800, quality: 85, format: 'webp' as const },
  }
  return getImageUrl(storagePath, sizes[size])
}

export function getBlurPlaceholderUrl(storagePath: string): string {
  return getImageUrl(storagePath, { width: 10, quality: 20, format: 'webp' })
}

export function getEmbedCode(storagePath: string, altText: string, width?: number): string {
  const url = getImageUrl(storagePath)
  const widthAttr = width ? ` width="${width}"` : ''
  return `<img src="${url}" alt="${altText}"${widthAttr} loading="lazy">`
}

export function formatFileSize(bytes: number): string {
  if (bytes < 1024) return `${bytes} B`
  if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`
  return `${(bytes / (1024 * 1024)).toFixed(1)} MB`
}
```

## Common mistakes

- **Using the transformation URL for downloads instead of the original** — Transformation URLs serve compressed WebP files. If a user downloads the image expecting the original JPEG, they get a converted copy instead. Fix: Use the original storage URL (without transform parameters) for the download link. Only use transformation URLs for thumbnails and gallery display.
- **Making the Storage bucket private when you want public sharing** — Private buckets require signed URLs that expire. If you generate a signed URL for an embed code, that embed will break when the URL expires. Fix: For an image hosting platform with public sharing, use a public bucket. Apply access control at the application layer by controlling which images appear in search and user profiles, not by restricting URL access.
- **Not setting breakInside: 'avoid' on masonry items** — CSS columns will split a tall image across column boundaries, cutting the image in half visually. Fix: Set break-inside: avoid (breakInside: 'avoid' in React inline styles) on every item container in the masonry layout. This forces each image to stay within one column.
- **Incrementing view_count on every page load, including bots** — Bots crawling shared image pages will inflate view counts, making the metric meaningless. Fix: Increment view count only after a human interaction signal — for example, after the image has been visible in the viewport for at least 2 seconds using an IntersectionObserver with a 2-second threshold, or only for authenticated users.

## Best practices

- Use Supabase Storage's built-in image transformation instead of a third-party CDN for thumbnail generation. The transform parameters (width, quality, format, resize) handle the most common use cases and cost nothing extra on all Supabase plans.
- Generate slugs at the database level using PostgreSQL defaults rather than in application code. This prevents race conditions where two simultaneous uploads could theoretically generate the same slug.
- Store width_px and height_px in the images table so the masonry grid can calculate the aspect ratio for each image before it loads. This prevents layout shifts as images load in.
- Use loading='lazy' on all img tags in the gallery. The browser's native lazy loading defers off-screen images automatically without any JavaScript intersection observer code.
- Clean up Storage objects when rows are deleted. Add a Supabase Database Webhook on the images table for DELETE events that calls a cleanup Edge Function. Orphaned storage objects cost money and cannot be recovered by querying the database.
- Limit file size in the Storage bucket policy (10MB is reasonable for most use cases). Also validate file size client-side before starting the upload to give immediate feedback without wasting bandwidth.

## Frequently asked questions

### Does Supabase Storage support image transformation on the free tier?

Yes. Image transformation (resizing, format conversion, quality adjustment) is available on all Supabase plans including free. You use it by passing a transform object to getPublicUrl. The transformed image is served from Supabase's CDN and cached for subsequent requests, so the transformation only runs once per unique set of parameters.

### How do I get image dimensions (width and height) at upload time?

Before uploading, create an Image object in JavaScript: const img = new Image(); img.onload = () => { width = img.naturalWidth; height = img.naturalHeight }; img.src = URL.createObjectURL(file). Read the dimensions in the onload callback and include them in the Supabase INSERT. Revoke the object URL after reading with URL.revokeObjectURL(img.src) to free memory.

### Can I allow anonymous uploads without requiring an account?

Yes. Set owner_id as nullable in the images table and allow INSERT on images without the owner_id = auth.uid() restriction (add WITH CHECK (true) for anonymous inserts). Unauthenticated uploads will have owner_id = null. Note that anonymous users cannot delete or manage their uploads later, so consider showing a 'Create account to manage your images' prompt after an anonymous upload.

### How do I prevent duplicate uploads of the same image?

Calculate the SHA-256 hash of the file in the browser before uploading using the Web Crypto API: crypto.subtle.digest('SHA-256', await file.arrayBuffer()). Store the hash in an image_hash column on the images table with a unique constraint. If the INSERT fails with a unique constraint violation, the image already exists — redirect the user to the existing image instead.

### What happens to images when a user deletes their account?

Add a Database Webhook on auth.users for DELETE events that triggers a Supabase Edge Function. The function fetches all storage_path values for the deleted user's images, calls supabase.storage.from('images').remove(paths) in batches of 100, then deletes the images table rows. Cascade deletes on album_images handle the join table cleanup automatically.

### How do I serve images from a custom domain?

Supabase Pro and above support custom storage domains via their CDN settings. Alternatively, set up a Cloudflare Worker or Vercel Rewrite that proxies requests from your domain to the Supabase Storage URL. This lets you serve images from images.yourdomain.com while keeping all the storage in Supabase.

### Is there help available for production image platform builds?

RapidDev builds production-grade Lovable apps including image hosting platforms with advanced moderation, storage quotas, and CDN optimization. Reach out if your image platform needs expert development.

---

Source: https://www.rapidevelopers.com/how-to-build-lovable/image-hosting
© RapidDev — https://www.rapidevelopers.com/how-to-build-lovable/image-hosting
