Skip to main content
RapidDev - Software Development Agency
supabase-tutorial

How to Display Images from Supabase Storage

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.

What you'll learn

  • How to get public URLs for images in public Supabase Storage buckets
  • How to generate signed URLs for images in private buckets
  • How to use Supabase image transformations for thumbnails and resizing
  • How to render Supabase Storage images in React and HTML
Book a free consultation
4.9Clutch rating
600+Happy partners
17+Countries served
190+Team members
Intermediate9 min read10-15 minSupabase (all plans), @supabase/supabase-js v2+, any frontend frameworkMarch 2026RapidDev Engineering Team
TL;DR

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

1

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.

typescript
1import { createClient } from '@supabase/supabase-js'
2
3const supabase = createClient(
4 'https://your-project.supabase.co',
5 'your-anon-key'
6)
7
8// Get public URL (synchronous, no API call)
9const { data } = supabase.storage
10 .from('public-images')
11 .getPublicUrl('avatars/user-123.jpg')
12
13console.log(data.publicUrl)
14// https://your-project.supabase.co/storage/v1/object/public/public-images/avatars/user-123.jpg
15
16// Use in an img tag
17// <img src={data.publicUrl} alt="User avatar" />

Expected result: A permanent public URL that can be used directly in img tags without authentication.

2

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.

typescript
1// Generate a signed URL that expires in 1 hour (3600 seconds)
2const { data, error } = await supabase.storage
3 .from('private-images')
4 .createSignedUrl('documents/invoice-42.pdf', 3600)
5
6if (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 link
11}
12
13// Generate multiple signed URLs at once
14const { data: urls, error: batchError } = await supabase.storage
15 .from('private-images')
16 .createSignedUrls(
17 ['photos/img1.jpg', 'photos/img2.jpg', 'photos/img3.jpg'],
18 3600
19 )
20
21if (!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.

3

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.

typescript
1// Public URL with transformation (thumbnail)
2const { data: thumbnail } = supabase.storage
3 .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 format
11 },
12 })
13
14// Signed URL with transformation
15const { data: signedThumb } = await supabase.storage
16 .from('private-images')
17 .createSignedUrl('photos/portrait.jpg', 3600, {
18 transform: {
19 width: 150,
20 height: 150,
21 resize: 'cover',
22 },
23 })
24
25// Responsive image set
26const sizes = [100, 300, 600, 1200]
27const srcSet = sizes.map((w) => {
28 const { data } = supabase.storage
29 .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.

4

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.

typescript
1import { useState, useEffect } from 'react'
2import { supabase } from '@/lib/supabase'
3
4interface StorageImageProps {
5 bucket: string
6 path: string
7 alt: string
8 width?: number
9 height?: number
10 isPublic?: boolean
11}
12
13export function StorageImage({ bucket, path, alt, width, height, isPublic = true }: StorageImageProps) {
14 const [url, setUrl] = useState<string | null>(null)
15 const [error, setError] = useState(false)
16
17 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])
32
33 if (error) return <div>Failed to load image</div>
34 if (!url) return <div>Loading...</div>
35
36 return (
37 <img
38 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.

5

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.

typescript
1-- Allow authenticated users to view their own files
2create policy "Users can view own files"
3on storage.objects for select
4to authenticated
5using (
6 bucket_id = 'private-images'
7 and auth.uid()::text = (storage.foldername(name))[1]
8);
9
10-- Allow authenticated users to upload to their own folder
11create policy "Users can upload own files"
12on storage.objects for insert
13to authenticated
14with check (
15 bucket_id = 'private-images'
16 and auth.uid()::text = (storage.foldername(name))[1]
17);
18
19-- Allow anyone to view files in a public bucket
20create policy "Public read access"
21on storage.objects for select
22to anon, authenticated
23using ( bucket_id = 'public-images' );

Expected result: RLS policies control who can view and upload images, preventing unauthorized access to private files.

Complete working example

StorageImage.tsx
1// Reusable React component for displaying Supabase Storage images
2import { useState, useEffect } from 'react'
3import { createClient } from '@supabase/supabase-js'
4
5const supabase = createClient(
6 process.env.NEXT_PUBLIC_SUPABASE_URL!,
7 process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!
8)
9
10interface StorageImageProps {
11 bucket: string
12 path: string
13 alt: string
14 width?: number
15 height?: number
16 isPublic?: boolean
17 className?: string
18 fallback?: string
19}
20
21export 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)
34
35 useEffect(() => {
36 setLoading(true)
37 setError(false)
38
39 const transform = width
40 ? { width, height: height || width, resize: 'cover' as const }
41 : undefined
42
43 if (isPublic) {
44 const { data } = supabase.storage
45 .from(bucket)
46 .getPublicUrl(path, { transform })
47 setUrl(data.publicUrl)
48 setLoading(false)
49 } else {
50 supabase.storage
51 .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])
63
64 if (loading) {
65 return <div className={`animate-pulse bg-gray-200 ${className}`} />
66 }
67
68 return (
69 <img
70 src={error || !url ? fallback : url}
71 alt={alt}
72 className={className}
73 loading="lazy"
74 onError={() => setError(true)}
75 />
76 )
77}
78
79// 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.

ChatGPT Prompt

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.

Supabase Prompt

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.

RapidDev

Talk to an Expert

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

Book a free consultation

Need help with your project?

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.