To display images from Supabase Storage, use getPublicUrl() for public buckets or createSignedUrl() for private buckets. For public buckets, the URL is permanent and requires no authentication. For private buckets, signed URLs expire after a configurable duration. Supabase also supports on-the-fly image transformations for resizing and format conversion, letting you generate thumbnails without a separate image processing service.
Displaying Images from Supabase Storage in Your Frontend
Supabase Storage provides S3-compatible object storage with built-in access control. Displaying images from Storage requires different approaches depending on whether your bucket is public or private. This tutorial covers fetching URLs for both types, applying image transformations for responsive thumbnails, and rendering images in React components with proper loading states and error handling.
Prerequisites
- A Supabase project with Storage enabled
- At least one storage bucket with uploaded images
- @supabase/supabase-js installed in your frontend project
- Basic understanding of React or HTML img tags
Step-by-step guide
Get a public URL for images in a public bucket
Get a public URL for images in a public bucket
If your bucket is set to public (anyone can access files without authentication), use getPublicUrl() to generate a permanent, direct URL. This method is synchronous — it constructs the URL locally without making an API request. Public URLs never expire and do not require auth tokens, making them ideal for profile pictures, product images, and any content that should be freely accessible.
1import { createClient } from '@supabase/supabase-js'23const supabase = createClient(4 'https://your-project.supabase.co',5 'your-anon-key'6)78// Get public URL (synchronous, no API call)9const { data } = supabase.storage10 .from('public-images')11 .getPublicUrl('avatars/user-123.jpg')1213console.log(data.publicUrl)14// https://your-project.supabase.co/storage/v1/object/public/public-images/avatars/user-123.jpg1516// Use in an img tag17// <img src={data.publicUrl} alt="User avatar" />Expected result: A permanent public URL that can be used directly in img tags without authentication.
Generate a signed URL for images in a private bucket
Generate a signed URL for images in a private bucket
Private bucket images require authentication. Use createSignedUrl() to generate a temporary URL that expires after a specified number of seconds. This is an async operation that makes an API call to Supabase. Signed URLs are ideal for user-uploaded documents, private photos, or any content that should only be accessible to authorized users for a limited time.
1// Generate a signed URL that expires in 1 hour (3600 seconds)2const { data, error } = await supabase.storage3 .from('private-images')4 .createSignedUrl('documents/invoice-42.pdf', 3600)56if (error) {7 console.error('Error creating signed URL:', error.message)8} else {9 console.log(data.signedUrl)10 // Use in an img tag or download link11}1213// Generate multiple signed URLs at once14const { data: urls, error: batchError } = await supabase.storage15 .from('private-images')16 .createSignedUrls(17 ['photos/img1.jpg', 'photos/img2.jpg', 'photos/img3.jpg'],18 360019 )2021if (!batchError && urls) {22 urls.forEach((item) => {23 console.log(item.path, item.signedUrl)24 })25}Expected result: Temporary signed URLs are generated that grant access to private files for the specified duration.
Apply image transformations for thumbnails
Apply image transformations for thumbnails
Supabase Storage supports on-the-fly image transformations for resizing, cropping, and format conversion. Pass a transform object to getPublicUrl() or createSignedUrl() to get a transformed version of the image. This eliminates the need for a separate image processing service or pre-generating thumbnails. Transformations are cached after the first request.
1// Public URL with transformation (thumbnail)2const { data: thumbnail } = supabase.storage3 .from('public-images')4 .getPublicUrl('products/shoe.jpg', {5 transform: {6 width: 200,7 height: 200,8 resize: 'cover', // 'cover', 'contain', or 'fill'9 quality: 80,10 format: 'origin', // 'origin' keeps original format11 },12 })1314// Signed URL with transformation15const { data: signedThumb } = await supabase.storage16 .from('private-images')17 .createSignedUrl('photos/portrait.jpg', 3600, {18 transform: {19 width: 150,20 height: 150,21 resize: 'cover',22 },23 })2425// Responsive image set26const sizes = [100, 300, 600, 1200]27const srcSet = sizes.map((w) => {28 const { data } = supabase.storage29 .from('public-images')30 .getPublicUrl('hero/banner.jpg', {31 transform: { width: w },32 })33 return `${data.publicUrl} ${w}w`34}).join(', ')Expected result: Transformed image URLs are generated for thumbnails and responsive image sets.
Render images in a React component with loading states
Render images in a React component with loading states
Build a React component that displays images from Supabase Storage with proper loading states, error handling, and fallback images. For public buckets, construct URLs directly. For private buckets, fetch signed URLs in a useEffect hook. Always handle the case where the image fails to load.
1import { useState, useEffect } from 'react'2import { supabase } from '@/lib/supabase'34interface StorageImageProps {5 bucket: string6 path: string7 alt: string8 width?: number9 height?: number10 isPublic?: boolean11}1213export function StorageImage({ bucket, path, alt, width, height, isPublic = true }: StorageImageProps) {14 const [url, setUrl] = useState<string | null>(null)15 const [error, setError] = useState(false)1617 useEffect(() => {18 if (isPublic) {19 const { data } = supabase.storage.from(bucket).getPublicUrl(path, {20 transform: width ? { width, height: height || width } : undefined,21 })22 setUrl(data.publicUrl)23 } else {24 supabase.storage.from(bucket).createSignedUrl(path, 3600, {25 transform: width ? { width, height: height || width } : undefined,26 }).then(({ data, error: err }) => {27 if (err) setError(true)28 else setUrl(data.signedUrl)29 })30 }31 }, [bucket, path, width, height, isPublic])3233 if (error) return <div>Failed to load image</div>34 if (!url) return <div>Loading...</div>3536 return (37 <img38 src={url}39 alt={alt}40 onError={() => setError(true)}41 loading="lazy"42 />43 )44}Expected result: A reusable React component that displays Supabase Storage images with loading and error states.
Set up RLS policies for storage access
Set up RLS policies for storage access
For private buckets, you need RLS policies on the storage.objects table to control who can view files. Without SELECT policies, users will get 403 errors when trying to access private files via signed URLs. Policies use the bucket_id and folder path to scope access. For user-specific files, use the auth.uid() function to match folder names.
1-- Allow authenticated users to view their own files2create policy "Users can view own files"3on storage.objects for select4to authenticated5using (6 bucket_id = 'private-images'7 and auth.uid()::text = (storage.foldername(name))[1]8);910-- Allow authenticated users to upload to their own folder11create policy "Users can upload own files"12on storage.objects for insert13to authenticated14with check (15 bucket_id = 'private-images'16 and auth.uid()::text = (storage.foldername(name))[1]17);1819-- Allow anyone to view files in a public bucket20create policy "Public read access"21on storage.objects for select22to anon, authenticated23using ( bucket_id = 'public-images' );Expected result: RLS policies control who can view and upload images, preventing unauthorized access to private files.
Complete working example
1// Reusable React component for displaying Supabase Storage images2import { useState, useEffect } from 'react'3import { createClient } from '@supabase/supabase-js'45const supabase = createClient(6 process.env.NEXT_PUBLIC_SUPABASE_URL!,7 process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!8)910interface StorageImageProps {11 bucket: string12 path: string13 alt: string14 width?: number15 height?: number16 isPublic?: boolean17 className?: string18 fallback?: string19}2021export function StorageImage({22 bucket,23 path,24 alt,25 width,26 height,27 isPublic = true,28 className = '',29 fallback = '/placeholder.png',30}: StorageImageProps) {31 const [url, setUrl] = useState<string | null>(null)32 const [loading, setLoading] = useState(true)33 const [error, setError] = useState(false)3435 useEffect(() => {36 setLoading(true)37 setError(false)3839 const transform = width40 ? { width, height: height || width, resize: 'cover' as const }41 : undefined4243 if (isPublic) {44 const { data } = supabase.storage45 .from(bucket)46 .getPublicUrl(path, { transform })47 setUrl(data.publicUrl)48 setLoading(false)49 } else {50 supabase.storage51 .from(bucket)52 .createSignedUrl(path, 3600, { transform })53 .then(({ data, error: err }) => {54 if (err || !data) {55 setError(true)56 } else {57 setUrl(data.signedUrl)58 }59 setLoading(false)60 })61 }62 }, [bucket, path, width, height, isPublic])6364 if (loading) {65 return <div className={`animate-pulse bg-gray-200 ${className}`} />66 }6768 return (69 <img70 src={error || !url ? fallback : url}71 alt={alt}72 className={className}73 loading="lazy"74 onError={() => setError(true)}75 />76 )77}7879// Usage examples:80// <StorageImage bucket="avatars" path="user-123/profile.jpg" alt="Profile" width={100} />81// <StorageImage bucket="documents" path="user-123/id.jpg" alt="ID" isPublic={false} />Common mistakes when displaying Images from Supabase Storage
Why it's a problem: Using getPublicUrl() on a private bucket, resulting in 403 errors when the image loads
How to avoid: getPublicUrl() only works for public buckets. For private buckets, use createSignedUrl() which generates a temporary authenticated URL.
Why it's a problem: Forgetting that getPublicUrl() does not verify the file exists, leading to broken images
How to avoid: getPublicUrl() constructs the URL locally without checking the server. Verify file paths are correct, including case sensitivity. Use the storage.from().list() method to confirm files exist.
Why it's a problem: Caching signed URLs that have expired, showing broken images to returning users
How to avoid: Regenerate signed URLs when they are about to expire. Set expiry times based on your use case — short (60s) for sensitive content, longer (3600s) for session-based viewing.
Why it's a problem: Missing RLS SELECT policy on storage.objects, causing 403 errors for private bucket access
How to avoid: Create a SELECT policy on storage.objects for the bucket. Without it, even authenticated users cannot generate signed URLs or access private files.
Best practices
- Use public buckets for content that should be freely accessible (marketing images, product photos) and private buckets for user-specific content
- Use getPublicUrl() for public buckets (synchronous, no API call) and createSignedUrl() for private buckets
- Apply image transformations via the transform option to serve properly sized images without a separate image service
- Use createSignedUrls() (plural) for batch URL generation in galleries instead of individual calls
- Add loading='lazy' to img tags to defer loading off-screen images for better performance
- Always include onError handlers on img tags with fallback images for a robust user experience
- Set up RLS policies on storage.objects for private buckets, scoping access by user ID folder pattern
- Use folder patterns like {user_id}/filename to organize user-specific files and simplify RLS policies
Still stuck?
Copy one of these prompts to get a personalized, step-by-step explanation.
I need to build an image gallery in React that displays photos from a private Supabase Storage bucket. Show me how to fetch signed URLs for a list of images, display them with loading skeletons, handle errors with fallback images, and refresh expired URLs automatically.
Create a React component that displays a user's profile avatar from Supabase Storage with a thumbnail transformation (100x100, cover). Support both public and private buckets, include a loading placeholder, and handle the case where no avatar has been uploaded yet with a default avatar.
Frequently asked questions
What is the difference between getPublicUrl() and createSignedUrl()?
getPublicUrl() generates a permanent URL for files in public buckets — it is synchronous and makes no API call. createSignedUrl() generates a temporary authenticated URL for files in private buckets — it is async and the URL expires after the specified duration.
Do image transformations work on the free plan?
Image transformations have limited usage on the free plan. Pro plan and above includes more generous transformation quotas. Check the Storage section in your project's usage dashboard for current limits.
Can I serve images from Supabase Storage through a CDN?
Supabase Storage is already served through a CDN for public buckets. Public URLs are cached at the edge. For signed URLs (private buckets), CDN caching is limited because each URL is unique and expires.
Why does my image URL return a 403 error?
For private buckets, you need a SELECT policy on storage.objects. Check that the policy matches the bucket_id and the user has permission. For public buckets, ensure the bucket is actually set to public in Dashboard > Storage.
How do I display images uploaded by other users?
For public content, use a public bucket and getPublicUrl(). For private content visible to specific users, write RLS policies that grant SELECT access based on your access model (e.g., team membership, friend connections).
What image formats does Supabase Storage support?
Supabase Storage accepts any file format for upload. Image transformations support JPEG, PNG, WebP, and GIF. The transform.format option can convert between these formats on the fly.
Can RapidDev help set up an image management system with Supabase Storage?
Yes. RapidDev can build complete image management systems including upload flows, thumbnail generation, access control with RLS policies, and CDN-optimized delivery using Supabase Storage.
Talk to an Expert
Our team has built 600+ apps. Get personalized help with your project.
Book a free consultation