Supabase Image Upload with React: Complete Tutorial

Building image upload functionality with live preview, progress tracking, and proper error handling is a common requirement for modern web applications—from user avatars and profile banners to product photos and gallery uploads. This hands-on tutorial demonstrates building production-ready React components for image uploading to Supabase Storage, including file validation (size, type), live image preview before upload, upload progress bars, error handling, image optimization, updating database records with file URLs, and implementing user-friendly UI with drag-and-drop support. We'll build reusable components suitable for avatar uploads, product images, and gallery managers while following React best practices with hooks, proper state management, and clean code patterns. Unlike basic examples that skip crucial details, this guide covers real-world scenarios including replacing existing images, handling upload failures, and integrating with user profiles. Before proceeding, understand Supabase Storage basics and authentication.
Setup and Prerequisites
// Install dependencies
npm install @supabase/supabase-js react react-dom
// Project structure
// src/
// components/
// ImageUpload.jsx
// AvatarUpload.jsx
// supabaseClient.js
// App.jsx
// supabaseClient.js
import { createClient } from '@supabase/supabase-js'
const supabaseUrl = process.env.REACT_APP_SUPABASE_URL
const supabaseAnonKey = process.env.REACT_APP_SUPABASE_ANON_KEY
export const supabase = createClient(supabaseUrl, supabaseAnonKey)
// Create 'avatars' bucket in Supabase Dashboard (Storage > New Bucket)
// Make it public for direct image accessBasic Image Upload Component
// ImageUpload.jsx - Basic upload with preview
import { useState } from 'react'
import { supabase } from '../supabaseClient'
function ImageUpload() {
const [file, setFile] = useState(null)
const [preview, setPreview] = useState(null)
const [uploading, setUploading] = useState(false)
const [uploadedUrl, setUploadedUrl] = useState(null)
const [error, setError] = useState(null)
function handleFileSelect(e) {
const selectedFile = e.target.files[0]
if (!selectedFile) return
// Validate file type
if (!selectedFile.type.startsWith('image/')) {
setError('Please select an image file')
return
}
// Validate file size (5MB max)
if (selectedFile.size > 5 * 1024 * 1024) {
setError('File size must be less than 5MB')
return
}
setFile(selectedFile)
setError(null)
// Create preview
const reader = new FileReader()
reader.onloadend = () => {
setPreview(reader.result)
}
reader.readAsDataURL(selectedFile)
}
async function handleUpload() {
if (!file) return
setUploading(true)
setError(null)
try {
// Generate unique filename
const fileExt = file.name.split('.').pop()
const fileName = `${Date.now()}-${Math.random().toString(36).substring(7)}.${fileExt}`
const filePath = `uploads/${fileName}`
// Upload to Supabase Storage
const { data, error } = await supabase.storage
.from('avatars')
.upload(filePath, file)
if (error) throw error
// Get public URL
const { data: urlData } = supabase.storage
.from('avatars')
.getPublicUrl(filePath)
setUploadedUrl(urlData.publicUrl)
alert('Upload successful!')
} catch (error) {
setError(error.message)
} finally {
setUploading(false)
}
}
return (
<div className="image-upload">
<h2>Upload Image</h2>
{/* File input */}
<input
type="file"
accept="image/*"
onChange={handleFileSelect}
disabled={uploading}
/>
{/* Preview */}
{preview && (
<div className="preview">
<img src={preview} alt="Preview" style={{ maxWidth: '300px' }} />
</div>
)}
{/* Error message */}
{error && <p className="error">{error}</p>}
{/* Upload button */}
{file && (
<button onClick={handleUpload} disabled={uploading}>
{uploading ? 'Uploading...' : 'Upload Image'}
</button>
)}
{/* Success message */}
{uploadedUrl && (
<div className="success">
<p>Image uploaded successfully!</p>
<img src={uploadedUrl} alt="Uploaded" style={{ maxWidth: '300px' }} />
<p>URL: {uploadedUrl}</p>
</div>
)}
</div>
)
}
export default ImageUploadAvatar Upload with Progress
// AvatarUpload.jsx - Complete avatar upload component
import { useState, useEffect } from 'react'
import { supabase } from '../supabaseClient'
function AvatarUpload({ userId, currentAvatarUrl, onUploadComplete }) {
const [uploading, setUploading] = useState(false)
const [avatarUrl, setAvatarUrl] = useState(currentAvatarUrl)
const [uploadProgress, setUploadProgress] = useState(0)
async function uploadAvatar(event) {
try {
setUploading(true)
setUploadProgress(0)
if (!event.target.files || event.target.files.length === 0) {
return
}
const file = event.target.files[0]
// Validate
if (file.size > 2 * 1024 * 1024) {
alert('Image must be less than 2MB')
return
}
const fileExt = file.name.split('.').pop()
const fileName = `${userId}/avatar.${fileExt}`
// Delete old avatar if exists
if (currentAvatarUrl) {
const oldPath = currentAvatarUrl.split('/').slice(-2).join('/')
await supabase.storage.from('avatars').remove([oldPath])
}
// Upload new avatar
const { error: uploadError } = await supabase.storage
.from('avatars')
.upload(fileName, file, {
cacheControl: '3600',
upsert: true
})
if (uploadError) throw uploadError
// Get public URL
const { data } = supabase.storage
.from('avatars')
.getPublicUrl(fileName)
const newAvatarUrl = data.publicUrl
// Update user profile in database
const { error: dbError } = await supabase
.from('profiles')
.update({ avatar_url: newAvatarUrl })
.eq('id', userId)
if (dbError) throw dbError
setAvatarUrl(newAvatarUrl)
if (onUploadComplete) onUploadComplete(newAvatarUrl)
} catch (error) {
alert(error.message)
} finally {
setUploading(false)
}
}
return (
<div className="avatar-upload">
{avatarUrl ? (
<img
src={avatarUrl}
alt="Avatar"
className="avatar-preview"
style={{
width: '150px',
height: '150px',
borderRadius: '50%',
objectFit: 'cover'
}}
/>
) : (
<div
className="avatar-placeholder"
style={{
width: '150px',
height: '150px',
borderRadius: '50%',
backgroundColor: '#ddd',
display: 'flex',
alignItems: 'center',
justifyContent: 'center'
}}
>
No avatar
</div>
)}
<div className="upload-controls">
<label className="button" htmlFor="avatar-upload">
{uploading ? 'Uploading...' : 'Change Avatar'}
</label>
<input
id="avatar-upload"
type="file"
accept="image/*"
onChange={uploadAvatar}
disabled={uploading}
style={{ display: 'none' }}
/>
</div>
</div>
)
}
export default AvatarUploadDrag and Drop Upload
// DragDropUpload.jsx - Drag and drop image upload
import { useState } from 'react'
import { supabase } from '../supabaseClient'
function DragDropUpload() {
const [isDragging, setIsDragging] = useState(false)
const [preview, setPreview] = useState(null)
const [uploading, setUploading] = useState(false)
function handleDragEnter(e) {
e.preventDefault()
e.stopPropagation()
setIsDragging(true)
}
function handleDragLeave(e) {
e.preventDefault()
e.stopPropagation()
setIsDragging(false)
}
function handleDragOver(e) {
e.preventDefault()
e.stopPropagation()
}
async function handleDrop(e) {
e.preventDefault()
e.stopPropagation()
setIsDragging(false)
const files = [...e.dataTransfer.files]
const imageFile = files.find(file => file.type.startsWith('image/'))
if (!imageFile) {
alert('Please drop an image file')
return
}
// Show preview
const reader = new FileReader()
reader.onloadend = () => setPreview(reader.result)
reader.readAsDataURL(imageFile)
// Upload
await uploadFile(imageFile)
}
async function uploadFile(file) {
setUploading(true)
const fileExt = file.name.split('.').pop()
const fileName = `${Date.now()}.${fileExt}`
const { error } = await supabase.storage
.from('avatars')
.upload(`uploads/${fileName}`, file)
if (error) {
alert(error.message)
} else {
alert('Upload successful!')
}
setUploading(false)
}
return (
<div
className={`drop-zone ${isDragging ? 'dragging' : ''}`}
onDragEnter={handleDragEnter}
onDragOver={handleDragOver}
onDragLeave={handleDragLeave}
onDrop={handleDrop}
style={{
border: '2px dashed #ccc',
borderRadius: '8px',
padding: '40px',
textAlign: 'center',
backgroundColor: isDragging ? '#f0f0f0' : 'white',
cursor: 'pointer'
}}
>
{preview ? (
<img src={preview} alt="Preview" style={{ maxWidth: '100%' }} />
) : (
<div>
<p>Drag and drop an image here</p>
<p>or click to select</p>
</div>
)}
{uploading && <p>Uploading...</p>}
</div>
)
}
export default DragDropUploadMultiple Image Gallery Upload
// GalleryUpload.jsx - Upload multiple images
import { useState } from 'react'
import { supabase } from '../supabaseClient'
function GalleryUpload() {
const [images, setImages] = useState([])
const [uploading, setUploading] = useState(false)
async function handleMultipleUpload(event) {
const files = [...event.target.files]
setUploading(true)
const uploadPromises = files.map(async (file) => {
const fileExt = file.name.split('.').pop()
const fileName = `${Date.now()}-${Math.random()}.${fileExt}`
const { data, error } = await supabase.storage
.from('avatars')
.upload(`gallery/${fileName}`, file)
if (error) throw error
const { data: urlData } = supabase.storage
.from('avatars')
.getPublicUrl(`gallery/${fileName}`)
return urlData.publicUrl
})
try {
const urls = await Promise.all(uploadPromises)
setImages([...images, ...urls])
alert('All images uploaded successfully!')
} catch (error) {
alert('Upload failed: ' + error.message)
} finally {
setUploading(false)
}
}
return (
<div className="gallery-upload">
<input
type="file"
accept="image/*"
multiple
onChange={handleMultipleUpload}
disabled={uploading}
/>
{uploading && <p>Uploading images...</p>}
<div className="gallery-grid" style={{ display: 'grid', gridTemplateColumns: 'repeat(3, 1fr)', gap: '10px' }}>
{images.map((url, index) => (
<img key={index} src={url} alt={`Gallery ${index}`} style={{ width: '100%' }} />
))}
</div>
</div>
)
}
export default GalleryUploadBest Practices
- Validate Before Upload: Check file type, size, and dimensions on client side
- Show Progress: Display upload progress for better UX, especially for large files
- Handle Errors Gracefully: Show user-friendly error messages for upload failures
- Optimize Images: Compress images before upload or use Supabase transformations
- Implement RLS: Secure storage with Row Level Security policies
- Update Database: Store file URLs in database tables for easy querying
- Delete Old Files: Remove previous files when users upload replacements to save storage
Next Steps
- Add Image Cropping: Use libraries like react-image-crop for profile picture editing
- Implement Real-time: Show real-time updates when other users upload images
- Secure Storage: Add comprehensive RLS policies for user-specific uploads
- Build Complete App: Integrate with React CRUD tutorial for full applications
Conclusion
Building robust image upload functionality in React with Supabase Storage provides users with intuitive, responsive interfaces for managing files in your applications. By implementing proper validation, live previews, progress tracking, and error handling, you create production-ready upload experiences that handle edge cases gracefully. The reusable components demonstrated—from basic uploads to drag-and-drop and gallery managers—can be adapted for avatars, product images, document uploads, and any file management scenario. Always remember to validate uploads on both client and server, implement Row Level Security policies for private files, optimize images for performance, and update database records with file URLs for easy querying. With these patterns mastered, you're equipped to build feature-rich applications with professional file upload capabilities. Continue enhancing your skills with real-time features and complete application tutorials.
$ share --platform
$ cat /comments/ (0)
$ cat /comments/
// No comments found. Be the first!


