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
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
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.
1Create an image hosting platform with Supabase. Set up these tables:23- 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_at4- 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_at5- album_images: id, album_id (references albums), image_id (references images), position (int), added_at67RLS: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 owner1112Create 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+xml15- Max upload size: 10MB1617Create 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.
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.
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'67type UploadFile = { file: File; progress: number; status: 'queued' | 'uploading' | 'done' | 'error'; imageId?: string }89function 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}1213export function ImageUploader({ onUploaded }: { onUploaded: (ids: string[]) => void }) {14 const [files, setFiles] = useState<UploadFile[]>([])15 const [dragging, setDragging] = useState(false)1617 const addFiles = (incoming: FileList | null) => {18 if (!incoming) return19 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 }2425 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 return42 }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 }5657 return (58 <div className="space-y-4">59 <div60 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.
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.
1Build a MasonryGallery component at src/components/gallery/MasonryGallery.tsx.23Props: images (array of { id, slug, storage_path, title, width_px, height_px, view_count })45Logic: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 button18 - Download button (anchor tag pointing to original storage URL)19 - Embed code: <img src='...' alt='...'> in a code block with a Copy button20 - 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.
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.
1Build album management pages.231. 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.11122. 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.
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.
1Build two public pages:231. 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.12132. 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
1import { supabase } from '@/integrations/supabase/client'23type TransformOptions = {4 width?: number5 height?: number6 quality?: number7 format?: 'webp' | 'jpeg' | 'png'8 resize?: 'cover' | 'contain' | 'fill'9}1011export function getImageUrl(storagePath: string, transform?: TransformOptions): string {12 if (!transform) {13 const { data } = supabase.storage.from('images').getPublicUrl(storagePath)14 return data.publicUrl15 }16 const { data } = supabase.storage.from('images').getPublicUrl(storagePath, { transform })17 return data.publicUrl18}1920export 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}2829export function getBlurPlaceholderUrl(storagePath: string): string {30 return getImageUrl(storagePath, { width: 10, quality: 20, format: 'webp' })31}3233export 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}3839export 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.
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'.
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.
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.
Talk to an Expert
Our team has built 600+ apps. Get personalized help with your project.
Book a free consultation