Skip to main content
RapidDev - Software Development Agency

How to Build a Image Hosting with Lovable

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.

What you'll build

  • Drag-and-drop multi-image upload to a public Supabase Storage bucket with progress indicators
  • Masonry grid gallery with responsive column count using CSS columns layout
  • Supabase Storage image transformation URLs for thumbnails, WebP conversion, and quality control
  • Album creation and management with cover images and ordered image sequences
  • Shareable public links for individual images and entire albums with copy-to-clipboard
  • Image detail page with EXIF-like metadata display, download button, and embed code
  • User dashboard showing all uploaded images, storage used, and view count totals
Book a free consultation
4.9Clutch rating
600+Happy partners
17+Countries served
190+Team members
Intermediate16 min read3–4 hoursLovable Pro or higherApril 2026RapidDev Engineering Team
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.

What you're building

Supabase Storage includes built-in image transformation that resizes, crops, and converts images on the fly. A 4MB JPEG upload becomes a 50KB WebP thumbnail by appending transformation parameters to the public URL — no Lambda, no Cloudinary, no extra services needed.

Images are stored in a public Supabase Storage bucket. Metadata (title, alt text, dimensions, file size, view count, upload date) lives in an images table in Supabase. The public URL is constructed from the storage_path column using supabase.storage.from('images').getPublicUrl(path).

The masonry grid uses the CSS columns property rather than a JavaScript masonry library. This keeps the layout purely declarative and avoids reflow calculations. Thumbnails use transformation URLs at a fixed width, so the browser downloads small images for the grid and only loads the full resolution when the user opens the lightbox.

Albums are ordered sequences of images. A shareable album link shows the album's masonry grid to anyone with the URL, even without an account. Individual image links follow a /i/{slug} pattern with a short random slug for easy sharing.

Final result

A working image hosting platform where users upload images by dragging them in, organize them into albums, and share links that load transformed thumbnails blazingly fast.

Tech stack

LovableFrontend
SupabaseDatabase, Auth, Storage
Supabase Storage TransformationsThumbnail generation and resizing
shadcn/uiUI Components
Tailwind CSSMasonry grid and responsive layout

Prerequisites

  • 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

Build steps

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.

prompt.txt
1Create an image hosting platform with Supabase. Set up these tables:
2
3- 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
4- 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
5- album_images: id, album_id (references albums), image_id (references images), position (int), added_at
6
7RLS:
8- images: SELECT is public for all rows (anonymous image hosting). INSERT requires authentication. UPDATE/DELETE requires owner_id = auth.uid()
9- albums: SELECT is public for visibility = 'public'. All CRUD for private albums requires owner_id = auth.uid()
10- album_images: SELECT inherits parent album visibility. Modify requires album owner
11
12Create a Supabase Storage bucket called 'images':
13- Public bucket (allow public URL access)
14- Allowed MIME types: image/jpeg, image/png, image/gif, image/webp, image/svg+xml
15- Max upload size: 10MB
16
17Create 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.

src/components/upload/ImageUploader.tsx
1import { useCallback, useState } from 'react'
2import { supabase } from '@/integrations/supabase/client'
3import { Progress } from '@/components/ui/progress'
4import { Button } from '@/components/ui/button'
5import { X, Upload, ImageIcon } from 'lucide-react'
6
7type UploadFile = { file: File; progress: number; status: 'queued' | 'uploading' | 'done' | 'error'; imageId?: string }
8
9function randomSlug(n: number) {
10 return Array.from(crypto.getRandomValues(new Uint8Array(n))).map((b) => b.toString(16).padStart(2, '0')).join('').slice(0, n)
11}
12
13export function ImageUploader({ onUploaded }: { onUploaded: (ids: string[]) => void }) {
14 const [files, setFiles] = useState<UploadFile[]>([])
15 const [dragging, setDragging] = useState(false)
16
17 const addFiles = (incoming: FileList | null) => {
18 if (!incoming) return
19 const newFiles = Array.from(incoming)
20 .filter((f) => f.type.startsWith('image/'))
21 .map((file) => ({ file, progress: 0, status: 'queued' as const }))
22 setFiles((prev) => [...prev, ...newFiles])
23 }
24
25 const uploadAll = async () => {
26 const uploadedIds: string[] = []
27 await Promise.all(
28 files.filter((f) => f.status === 'queued').map(async (entry, i) => {
29 const slug = randomSlug(8)
30 const ext = entry.file.name.split('.').pop() ?? 'jpg'
31 const path = `${slug}.${ext}`
32 setFiles((prev) => prev.map((f) => f === entry ? { ...f, status: 'uploading' } : f))
33 const { error } = await supabase.storage.from('images').upload(path, entry.file, {
34 onUploadProgress: ({ loaded, total }) => {
35 const pct = Math.round((loaded / (total ?? 1)) * 100)
36 setFiles((prev) => prev.map((f) => f === entry ? { ...f, progress: pct } : f))
37 },
38 })
39 if (error) {
40 setFiles((prev) => prev.map((f) => f === entry ? { ...f, status: 'error' } : f))
41 return
42 }
43 const { data: row } = await supabase.from('images').insert({
44 slug,
45 storage_path: path,
46 file_size_bytes: entry.file.size,
47 mime_type: entry.file.type,
48 title: entry.file.name.replace(/\.[^.]+$/, ''),
49 }).select('id').single()
50 if (row) uploadedIds.push(row.id)
51 setFiles((prev) => prev.map((f) => f === entry ? { ...f, status: 'done', imageId: row?.id } : f))
52 })
53 )
54 onUploaded(uploadedIds)
55 }
56
57 return (
58 <div className="space-y-4">
59 <div
60 onDragOver={(e) => { e.preventDefault(); setDragging(true) }}
61 onDragLeave={() => setDragging(false)}
62 onDrop={(e) => { e.preventDefault(); setDragging(false); addFiles(e.dataTransfer.files) }}
63 onClick={() => document.getElementById('file-input')?.click()}
64 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'}`}
65 >
66 <Upload className="w-8 h-8 mx-auto mb-3 text-muted-foreground" />
67 <p className="text-sm font-medium">Drop images here or click to browse</p>
68 <p className="text-xs text-muted-foreground mt-1">JPEG, PNG, GIF, WebP up to 10MB each</p>
69 </div>
70 <input id="file-input" type="file" multiple accept="image/*" className="hidden" onChange={(e) => addFiles(e.target.files)} />
71 {files.length > 0 && (
72 <div className="space-y-2">
73 {files.map((f, i) => (
74 <div key={i} className="flex items-center gap-3">
75 <ImageIcon className="w-4 h-4 shrink-0 text-muted-foreground" />
76 <div className="flex-1 min-w-0">
77 <p className="text-sm truncate">{f.file.name}</p>
78 {f.status === 'uploading' && <Progress value={f.progress} className="h-1 mt-1" />}
79 </div>
80 <span className="text-xs text-muted-foreground shrink-0">
81 {f.status === 'done' ? 'Done' : f.status === 'error' ? 'Error' : f.status === 'uploading' ? `${f.progress}%` : 'Queued'}
82 </span>
83 {f.status !== 'uploading' && (
84 <Button variant="ghost" size="icon" className="h-6 w-6" onClick={(e) => { e.stopPropagation(); setFiles((prev) => prev.filter((_, j) => j !== i)) }}>
85 <X className="h-3 w-3" />
86 </Button>
87 )}
88 </div>
89 ))}
90 <Button onClick={uploadAll} className="w-full" disabled={files.every((f) => f.status !== 'queued')}>
91 Upload {files.filter((f) => f.status === 'queued').length} image{files.filter((f) => f.status === 'queued').length !== 1 ? 's' : ''}
92 </Button>
93 </div>
94 )}
95 </div>
96 )
97}

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.

prompt.txt
1Build a MasonryGallery component at src/components/gallery/MasonryGallery.tsx.
2
3Props: images (array of { id, slug, storage_path, title, width_px, height_px, view_count })
4
5Logic:
6- For each image, construct a thumbnail URL using Supabase Storage transformation:
7 const { data } = supabase.storage.from('images').getPublicUrl(storage_path, {
8 transform: { width: 400, format: 'webp', quality: 80 }
9 })
10- Layout: CSS columns, not flexbox or grid.
11 <div style={{ columnCount: columns, columnGap: '1rem' }}> where columns = 2 on mobile, 3 on tablet, 4 on desktop (use a custom hook useBreakpoint).
12 Each image: <div style={{ breakInside: 'avoid', marginBottom: '1rem' }}>
13- On hover, show a dark overlay with the image title and view count.
14- Clicking an image opens a shadcn/ui Dialog lightbox showing:
15 - Full-resolution image (not the thumbnail URL)
16 - Title (editable inline for the owner)
17 - Slug-based share URL: window.location.origin + '/i/' + slug with a Copy button
18 - Download button (anchor tag pointing to original storage URL)
19 - Embed code: <img src='...' alt='...'> in a code block with a Copy button
20 - Delete button (only visible to the image owner)
21- 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.

prompt.txt
1Build album management pages.
2
31. Albums list page at src/pages/Albums.tsx:
4- Fetch all albums where owner_id = auth.uid() ordered by created_at desc.
5- Display as a grid of Cards with cover image (or grid of up to 4 thumbnails), title, image count, and visibility Badge.
6- 'New Album' Button opens a Dialog:
7 - title Input (required)
8 - description Textarea (optional)
9 - visibility Select (public/private)
10 - Submit inserts into albums table.
11
122. Album detail page at src/pages/AlbumDetail.tsx (route: /albums/[id]):
13- Fetch album metadata and all album_images joined with images, ordered by position.
14- Show the album title, description, and visibility Badge.
15- Render images in the MasonryGallery component.
16- '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.
17- 'Share Album' Button copies window.location.origin + '/a/' + album.slug to clipboard using navigator.clipboard.writeText.
18- For public albums, show a 'Make Private' Button. For private, show 'Make Public' Button.
19- '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.

prompt.txt
1Build two public pages:
2
31. Image page at src/pages/PublicImage.tsx (route: /i/[slug]):
4- Fetch image by slug from the images table (no auth required public SELECT policy).
5- If not found, show a 404 state with a Button linking back to the homepage.
6- On component mount, call supabase.rpc('increment_image_views', { p_image_id: image.id }).
7- Layout: centered image with max-width 1200px, metadata below.
8- Metadata row: file size formatted (e.g., '2.4 MB'), dimensions (e.g., '1920 x 1080'), upload date, view count.
9- Share row: copy URL Button, download Button (direct link to storage), embed Button.
10- Embed dialog: shows <img src='...' alt='...'> snippet in a shadcn/ui code block with one-click copy.
11- 'See all uploads by this user' link to /user/[owner_id] if the image has an owner.
12
132. Album page at src/pages/PublicAlbum.tsx (route: /a/[slug]):
14- Fetch album by slug. If visibility = 'private' and the viewer is not the owner, return a 403 state.
15- Show album title, description, and image count.
16- Render images using the MasonryGallery component.
17- '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

src/lib/imageTransform.ts
1import { supabase } from '@/integrations/supabase/client'
2
3type TransformOptions = {
4 width?: number
5 height?: number
6 quality?: number
7 format?: 'webp' | 'jpeg' | 'png'
8 resize?: 'cover' | 'contain' | 'fill'
9}
10
11export function getImageUrl(storagePath: string, transform?: TransformOptions): string {
12 if (!transform) {
13 const { data } = supabase.storage.from('images').getPublicUrl(storagePath)
14 return data.publicUrl
15 }
16 const { data } = supabase.storage.from('images').getPublicUrl(storagePath, { transform })
17 return data.publicUrl
18}
19
20export function getThumbnailUrl(storagePath: string, size: 'small' | 'medium' | 'large' = 'medium'): string {
21 const sizes = {
22 small: { width: 150, quality: 70, format: 'webp' as const },
23 medium: { width: 400, quality: 80, format: 'webp' as const },
24 large: { width: 800, quality: 85, format: 'webp' as const },
25 }
26 return getImageUrl(storagePath, sizes[size])
27}
28
29export function getBlurPlaceholderUrl(storagePath: string): string {
30 return getImageUrl(storagePath, { width: 10, quality: 20, format: 'webp' })
31}
32
33export function getEmbedCode(storagePath: string, altText: string, width?: number): string {
34 const url = getImageUrl(storagePath)
35 const widthAttr = width ? ` width="${width}"` : ''
36 return `<img src="${url}" alt="${altText}"${widthAttr} loading="lazy">`
37}
38
39export function formatFileSize(bytes: number): string {
40 if (bytes < 1024) return `${bytes} B`
41 if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`
42 return `${(bytes / (1024 * 1024)).toFixed(1)} MB`
43}

Customization ideas

Tag system with tag cloud

Add an image_tags table (image_id, tag text). On the upload form, add a multi-value tag Input that splits on commas and Enter. Display a tag cloud on the homepage where tag size corresponds to image count. Clicking a tag shows all images with that tag in a filtered gallery.

Favorites and collections for logged-in visitors

Add a favorites table (user_id, image_id, created_at). Any logged-in user can favorite any public image by clicking a heart icon. A /favorites page shows all favorited images in the masonry grid. The heart icon on each image shows the total favorite count.

Image moderation and reporting

Add a reports table (image_id, reporter_id, reason, status: pending|reviewed|removed). Add a 'Report' option in the image kebab menu. An admin dashboard lists reported images with the image preview, reporter reason, and approve/remove buttons. Removing marks the image row as deleted and calls a Supabase Edge Function to also delete from Storage.

Storage quota per user

Add a storage_used_bytes column to a user_settings table. After each successful upload, update this total by summing file_size_bytes for all images owned by the user. Show a storage usage bar in the user dashboard. Prevent uploads that would exceed the user's quota (e.g., 500MB for free, 5GB for paid) by checking before inserting.

Batch operations on the image library

Add a selection mode to the image library. When enabled, each image gets a checkbox. A bottom action bar appears when images are selected with buttons: Add to Album, Delete, Change Visibility, Download as ZIP. The ZIP download collects the original Storage URLs and zips them client-side using a library like JSZip.

Common pitfalls

Pitfall: Using the transformation URL for downloads instead of the original

How to avoid: Use the original storage URL (without transform parameters) for the download link. Only use transformation URLs for thumbnails and gallery display.

Pitfall: Making the Storage bucket private when you want public sharing

How to avoid: 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.

Pitfall: Not setting breakInside: 'avoid' on masonry items

How to avoid: 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.

Pitfall: Incrementing view_count on every page load, including bots

How to avoid: 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.

AI prompts to try

Copy these prompts to build this project faster.

ChatGPT Prompt

I'm building an image hosting platform in Lovable (React + Supabase Storage). I have an images table with columns: id, slug, storage_path, file_size_bytes, mime_type, view_count. I'm using a public Supabase Storage bucket called 'images'. Write a TypeScript utility module imageTransform.ts with functions: getImageUrl(storagePath, transform?) that uses supabase.storage.from('images').getPublicUrl with optional transform options, getThumbnailUrl(storagePath, size: 'small'|'medium'|'large') that maps sizes to specific width/quality/webp parameters, and formatFileSize(bytes) that returns human-readable strings like '2.4 MB'.

Lovable Prompt

Add an image search feature to the gallery page. Above the masonry grid, add a shadcn/ui Input for search. When the user types at least 2 characters, query images WHERE title ILIKE '%query%' OR alt_text ILIKE '%query%', limit 50, order by view_count desc. Display results in the existing masonry grid replacing the default view. Show a 'X results for query' badge above the grid. Show 'No results' state with a clear search Button if empty. Debounce the search input by 300ms.

Build Prompt

In Supabase Storage, I want to delete all images in the 'images' bucket that no longer have a corresponding row in the images table (orphaned files). Write a Supabase Edge Function (Deno) that: 1) Lists all objects in the images bucket using supabase.storage.from('images').list(). 2) Queries all storage_path values from the images table. 3) Identifies objects whose name is not in the images table paths. 4) Calls supabase.storage.from('images').remove([...orphanedPaths]) in batches of 100. Return { deleted: count }.

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.

RapidDev

Talk to an Expert

Our team has built 600+ apps. Get personalized help with your project.

Book a free consultation

Need help building your app?

Our experts have built 600+ apps and can accelerate your development. Book a free consultation — no strings attached.

Book a free consultation

We put the rapid in RapidDev

Need a dedicated strategic tech and growth partner? Discover what RapidDev can do for your business! Book a call with our team to schedule a free, no-obligation consultation. We'll discuss your project and provide a custom quote at no cost.