# How to Display Images from Supabase Storage

- Tool: Supabase
- Difficulty: Intermediate
- Time required: 10-15 min
- Compatibility: Supabase (all plans), @supabase/supabase-js v2+, any frontend framework
- Last updated: March 2026

## 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.

## Before you start

- 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.

```
import { createClient } from '@supabase/supabase-js'

const supabase = createClient(
  'https://your-project.supabase.co',
  'your-anon-key'
)

// Get public URL (synchronous, no API call)
const { data } = supabase.storage
  .from('public-images')
  .getPublicUrl('avatars/user-123.jpg')

console.log(data.publicUrl)
// https://your-project.supabase.co/storage/v1/object/public/public-images/avatars/user-123.jpg

// Use in an img tag
// <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.

```
// Generate a signed URL that expires in 1 hour (3600 seconds)
const { data, error } = await supabase.storage
  .from('private-images')
  .createSignedUrl('documents/invoice-42.pdf', 3600)

if (error) {
  console.error('Error creating signed URL:', error.message)
} else {
  console.log(data.signedUrl)
  // Use in an img tag or download link
}

// Generate multiple signed URLs at once
const { data: urls, error: batchError } = await supabase.storage
  .from('private-images')
  .createSignedUrls(
    ['photos/img1.jpg', 'photos/img2.jpg', 'photos/img3.jpg'],
    3600
  )

if (!batchError && urls) {
  urls.forEach((item) => {
    console.log(item.path, item.signedUrl)
  })
}
```

**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.

```
// Public URL with transformation (thumbnail)
const { data: thumbnail } = supabase.storage
  .from('public-images')
  .getPublicUrl('products/shoe.jpg', {
    transform: {
      width: 200,
      height: 200,
      resize: 'cover', // 'cover', 'contain', or 'fill'
      quality: 80,
      format: 'origin', // 'origin' keeps original format
    },
  })

// Signed URL with transformation
const { data: signedThumb } = await supabase.storage
  .from('private-images')
  .createSignedUrl('photos/portrait.jpg', 3600, {
    transform: {
      width: 150,
      height: 150,
      resize: 'cover',
    },
  })

// Responsive image set
const sizes = [100, 300, 600, 1200]
const srcSet = sizes.map((w) => {
  const { data } = supabase.storage
    .from('public-images')
    .getPublicUrl('hero/banner.jpg', {
      transform: { width: w },
    })
  return `${data.publicUrl} ${w}w`
}).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.

```
import { useState, useEffect } from 'react'
import { supabase } from '@/lib/supabase'

interface StorageImageProps {
  bucket: string
  path: string
  alt: string
  width?: number
  height?: number
  isPublic?: boolean
}

export function StorageImage({ bucket, path, alt, width, height, isPublic = true }: StorageImageProps) {
  const [url, setUrl] = useState<string | null>(null)
  const [error, setError] = useState(false)

  useEffect(() => {
    if (isPublic) {
      const { data } = supabase.storage.from(bucket).getPublicUrl(path, {
        transform: width ? { width, height: height || width } : undefined,
      })
      setUrl(data.publicUrl)
    } else {
      supabase.storage.from(bucket).createSignedUrl(path, 3600, {
        transform: width ? { width, height: height || width } : undefined,
      }).then(({ data, error: err }) => {
        if (err) setError(true)
        else setUrl(data.signedUrl)
      })
    }
  }, [bucket, path, width, height, isPublic])

  if (error) return <div>Failed to load image</div>
  if (!url) return <div>Loading...</div>

  return (
    <img
      src={url}
      alt={alt}
      onError={() => setError(true)}
      loading="lazy"
    />
  )
}
```

**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.

```
-- Allow authenticated users to view their own files
create policy "Users can view own files"
on storage.objects for select
to authenticated
using (
  bucket_id = 'private-images'
  and auth.uid()::text = (storage.foldername(name))[1]
);

-- Allow authenticated users to upload to their own folder
create policy "Users can upload own files"
on storage.objects for insert
to authenticated
with check (
  bucket_id = 'private-images'
  and auth.uid()::text = (storage.foldername(name))[1]
);

-- Allow anyone to view files in a public bucket
create policy "Public read access"
on storage.objects for select
to anon, authenticated
using ( bucket_id = 'public-images' );
```

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

## Complete code example

File: `StorageImage.tsx`

```typescript
// Reusable React component for displaying Supabase Storage images
import { useState, useEffect } from 'react'
import { createClient } from '@supabase/supabase-js'

const supabase = createClient(
  process.env.NEXT_PUBLIC_SUPABASE_URL!,
  process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!
)

interface StorageImageProps {
  bucket: string
  path: string
  alt: string
  width?: number
  height?: number
  isPublic?: boolean
  className?: string
  fallback?: string
}

export function StorageImage({
  bucket,
  path,
  alt,
  width,
  height,
  isPublic = true,
  className = '',
  fallback = '/placeholder.png',
}: StorageImageProps) {
  const [url, setUrl] = useState<string | null>(null)
  const [loading, setLoading] = useState(true)
  const [error, setError] = useState(false)

  useEffect(() => {
    setLoading(true)
    setError(false)

    const transform = width
      ? { width, height: height || width, resize: 'cover' as const }
      : undefined

    if (isPublic) {
      const { data } = supabase.storage
        .from(bucket)
        .getPublicUrl(path, { transform })
      setUrl(data.publicUrl)
      setLoading(false)
    } else {
      supabase.storage
        .from(bucket)
        .createSignedUrl(path, 3600, { transform })
        .then(({ data, error: err }) => {
          if (err || !data) {
            setError(true)
          } else {
            setUrl(data.signedUrl)
          }
          setLoading(false)
        })
    }
  }, [bucket, path, width, height, isPublic])

  if (loading) {
    return <div className={`animate-pulse bg-gray-200 ${className}`} />
  }

  return (
    <img
      src={error || !url ? fallback : url}
      alt={alt}
      className={className}
      loading="lazy"
      onError={() => setError(true)}
    />
  )
}

// Usage examples:
// <StorageImage bucket="avatars" path="user-123/profile.jpg" alt="Profile" width={100} />
// <StorageImage bucket="documents" path="user-123/id.jpg" alt="ID" isPublic={false} />
```

## Common mistakes

- **Using getPublicUrl() on a private bucket, resulting in 403 errors when the image loads** — undefined Fix: getPublicUrl() only works for public buckets. For private buckets, use createSignedUrl() which generates a temporary authenticated URL.
- **Forgetting that getPublicUrl() does not verify the file exists, leading to broken images** — undefined Fix: 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.
- **Caching signed URLs that have expired, showing broken images to returning users** — undefined Fix: 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.
- **Missing RLS SELECT policy on storage.objects, causing 403 errors for private bucket access** — undefined Fix: 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

## 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.

---

Source: https://www.rapidevelopers.com/supabase-tutorial/how-to-display-images-from-supabase-storage
© RapidDev — https://www.rapidevelopers.com/supabase-tutorial/how-to-display-images-from-supabase-storage
