# How to Add a Media Gallery to Your App (Copy-Paste Prompts Included)

- Tool: App Features
- Last updated: July 2026

## TL;DR

A media gallery needs four pieces: a file upload layer with MIME/size validation, Supabase Storage for file hosting, a metadata table for searching and ordering, and a grid UI with lightbox and video playback. With Lovable or FlutterFlow you can ship a working gallery in 3–6 hours for $0/month at small scale. Storage costs only appear past 1GB of uploads — roughly 1,000 active users.

## What a Media Gallery Feature Actually Is

A media gallery lets users upload, browse, and manage their own photos and videos inside your app — not just link to external content. The upload layer handles MIME validation and byte-level progress; Supabase Storage hosts the files behind per-user access policies; a metadata table records captions, types, and sort order; and the grid UI handles thumbnail generation, lightbox overlay, and video playback. The product decisions that matter most are the file size cap you enforce, whether users can caption or organize items, and whether privacy is enforced at the row level or left open. Getting storage RLS wrong is the most common reason galleries accidentally expose one user's photos to another.

## Anatomy of the Feature

Six components across four layers. AI tools scaffold the grid and upload widget reliably — the storage policy and video thumbnail layers are where builds stall.

- **Grid / Lightbox UI** (ui): Renders a staggered two- or three-column grid using React Native FlatList (or FlutterFlow's GridView widget). Tapping any item opens react-native-image-viewing as a full-screen lightbox with swipe navigation between all gallery items.
- **File upload layer** (backend): Accepts file objects from the device picker, validates MIME type (image/jpeg, image/png, video/mp4) and byte size before uploading to Supabase Storage via supabase-js. The onUploadProgress callback populates a per-file progress bar.
- **Media metadata store** (data): A PostgreSQL table (media_items) in Supabase storing user_id, storage_path, mime_type, file_size, caption, and created_at. Querying this table drives the grid — media files themselves are fetched by constructing signed download URLs from storage_path.
- **Video player** (ui): react-native-video (in React Native apps) or FlutterFlow's built-in VideoPlayer widget renders video items. Set autoplay=false and require a user tap on the play overlay so videos don't fire unexpectedly in a scroll list.
- **Image optimizer** (service): Supabase Storage's built-in image transformation API generates thumbnails on the fly: append ?width=400&quality=80 to any storage URL. No separate CDN or transcoding service is needed at under 10,000 users — Supabase Storage serves via its own global CDN.
- **RLS enforcement layer** (backend): Supabase Row Level Security policies on both the media_items table and the Storage bucket ensure each user can only read and write their own files. Without explicit policies, Supabase denies all access by default — but AI tools sometimes skip this step and leave buckets public.

## Data model

One table tracks media metadata with row-level security; run this in the Supabase SQL editor to set it up:

```sql
create table public.media_items (
  id uuid primary key default gen_random_uuid(),
  user_id uuid references auth.users(id) on delete cascade not null,
  storage_path text not null,
  mime_type text not null,
  file_size bigint not null,
  caption text,
  created_at timestamptz not null default now()
);

alter table public.media_items enable row level security;

create policy "Users can view own media"
  on public.media_items for select
  using (auth.uid() = user_id);

create policy "Users can insert own media"
  on public.media_items for insert
  with check (auth.uid() = user_id);

create policy "Users can delete own media"
  on public.media_items for delete
  using (auth.uid() = user_id);

create index media_items_user_created_idx
  on public.media_items (user_id, created_at desc);
```

The descending index on (user_id, created_at) keeps the gallery grid query fast as individual users accumulate hundreds of items. Storage bucket policies must be added separately in Supabase Dashboard → Storage → Policies: INSERT and SELECT both scoped to auth.uid() = user_id.

## Build paths

### Lovable — fit 3/10, 4–6 hours

Good path for a web-facing media gallery prototype. Lovable scaffolds the grid, lightbox, and Supabase upload wiring, but native mobile swipe gestures and video playback feel less polished than a Flutter build.

1. Create a new Lovable project and connect Lovable Cloud — Supabase Storage and the media_items table will be provisioned inside your Lovable Cloud project
2. Run the SQL schema from this page in the Supabase SQL editor before starting the prompt so the table is ready
3. Paste the prompt below in Agent Mode and let it generate the gallery page, upload dialog, lightbox, and video player component
4. Click Publish and test on the published HTTPS URL from a real mobile browser — video playback and upload progress do not work inside the Lovable preview iframe
5. Open Supabase Dashboard → Storage → Policies and confirm INSERT and SELECT policies are scoped to auth.uid() = user_id on your media bucket

Starter prompt:

```
Build a media gallery feature using Supabase Storage and a 'media_items' Supabase table (columns: id, user_id, storage_path, mime_type, file_size, caption, created_at). Gallery page: staggered 2-column grid on mobile, 3-column on tablet; each cell shows the image thumbnail (Supabase Storage URL with ?width=400&quality=80) or a video thumbnail with a play icon overlay. Tapping a cell opens a full-screen lightbox with swipe navigation between all items. File upload: accept image/jpeg, image/png, video/mp4 only; enforce a 10 MB per-file limit; show a per-file progress bar via onUploadProgress; disable the upload button during in-flight upload to prevent duplicates; show a retry button on upload failure. Include a delete confirmation dialog before removing any item. Show a caption below each item in the lightbox if caption is present. Add a loading skeleton grid on first render while media fetches. Show an empty-state illustration with 'Add your first photo or video' CTA when gallery has no items. Support pull-to-refresh. Use react-native-video for video playback with autoplay=false and native controls.
```

Limitations:

- Lovable is Vite/React not native — native camera roll integration is unavailable; gallery works from file picker only
- Video autoplay and poster thumbnails are blocked inside the Lovable preview iframe; test only on published URL
- react-native-video integration may require a follow-up prompt to fix import errors in Lovable's Vite environment

### Flutterflow — fit 5/10, 3–5 hours

Best path for mobile — GridView, VideoPlayer widget, and CachedNetworkImage all ship as built-in FlutterFlow components. Supabase Storage is natively supported alongside Firebase Storage.

1. Add a new page in FlutterFlow with a GridView widget (2 columns, cross-axis count); set each cell to a Stack with a CachedNetworkImage and a conditional play-icon overlay for video items
2. Add a FloatingActionButton that opens a file picker action; in the Action editor chain: Pick Media → Upload to Supabase Storage → Insert Row into media_items table
3. Wire the pull-to-refresh widget wrapping the GridView to re-query the media_items table filtered by auth.uid()
4. Add a lightbox page: GridView item tap navigates to a full-screen image/video page passing the storage_path as a route parameter
5. In Settings → Permissions, enable camera and photo library permissions with descriptive iOS usage strings; test on a real device via FlutterFlow's Test on Device feature

Limitations:

- FlutterFlow Pro ($70/mo) is required for code export and for custom Supabase queries beyond simple CRUD
- CachedNetworkImage caches the signed URL string, not the image bytes — signed URLs that expire before the TTL causes broken images; set expiry to 86400 seconds
- Free tier restricts advanced Supabase Storage policies; configure bucket RLS in the Supabase Dashboard directly

### Custom — fit 4/10, 1–2 weeks

Full control build for when the gallery is a core product differentiator — enables server-side compression, infinite scroll pagination, CDN edge caching, and AI auto-tagging.

1. React Native with expo-image-picker and expo-av for native camera roll access and video playback; or Next.js with react-dropzone for a web gallery
2. Server-side image compression via a Supabase Edge Function using the sharp library — reduces storage costs and improves load time before files reach the bucket
3. Infinite scroll with cursor-based pagination on media_items, keyed on (user_id, created_at DESC), to keep memory usage flat on large galleries
4. Google ML Kit Vision API integration for automatic image tagging and face grouping if the product roadmap requires smart search

Limitations:

- Highest effort — only justified when gallery is a primary product feature, not a supplementary one
- Server-side video transcoding (e.g., ffmpeg) requires a worker process outside Supabase Edge Functions — adds infrastructure complexity

## Gotchas

- **Video thumbnails are blank in the Lovable preview** — Lovable's preview iframe enforces a restrictive content security policy that blocks video autoplay and poster attribute loading. The video element renders but its thumbnail frame never loads, making it look like a broken feature. Fix: Click Publish → Update and open the live URL on a real mobile browser. Poster images and autoplay restrictions are lifted in the production deployment. Do all video testing outside the preview iframe.
- **Supabase Storage bucket left as public, exposing all user media** — AI tools frequently generate Supabase Storage code that creates a public bucket or omits Storage-level access policies entirely. Anyone with a valid storage URL can then view any user's photo — a serious privacy failure that is not caught by table-level RLS alone. Fix: In Supabase Dashboard → Storage → Policies, set the bucket to Private and add INSERT and SELECT policies scoped to (auth.uid() = user_id). Table RLS and Storage bucket policies are separate systems — both are required.
- **FlatList performance degrades at 100+ items** — AI-generated FlatList code typically omits virtualisation tuning props: no initialNumToRender, no maxToRenderPerBatch, no windowSize. At 100+ items, every scroll event triggers a burst of off-screen renders, causing dropped frames and memory growth. Fix: Add initialNumToRender={12}, maxToRenderPerBatch={12}, windowSize={5}, and a keyExtractor returning a unique string (item.id). Specify these props explicitly in your prompt — the AI will include them only if asked.
- **FlutterFlow's CachedNetworkImage caches stale signed URLs** — Supabase signed URLs expire after their TTL (default 1 hour). FlutterFlow's CachedNetworkImage widget caches the URL string itself, so after the TTL elapses the cached URL returns a 403, and the image shows a broken icon rather than refetching. Fix: Set signed URL expiry to 86400 seconds (24 hours) in your Supabase Storage createSignedUrl call. Add an AppState listener that triggers a signed URL refresh when the app resumes from background, replacing cached entries.
- **Duplicate media items created on upload retry** — When a user taps the upload button, sees a slow progress bar, and taps again, two upload requests fire in parallel. Both complete successfully, creating two media_items rows for the same file. The upload progress handler firing onUploadProgress does not prevent re-tap. Fix: Track upload state in a local state machine with four values: idle, uploading, done, and error. Disable the upload button (pointer-events: none + visual indicator) during the uploading state. Only return to idle on success or explicit user cancel — not on progress callback.

## Best practices

- Enforce MIME type and file size validation before the upload starts — reject files larger than 10 MB or with unsupported types client-side, not in a server error response
- Use Supabase Storage image transformations (?width=400&quality=80) for grid thumbnails instead of loading original files — a 5 MB JPEG scaled to 400px saves 90% of bandwidth per grid cell
- Store storage_path in your metadata table, not the full signed URL — signed URLs expire and change; constructing them from a stable path keeps your data clean
- Set both table-level RLS policies and Storage bucket-level policies — they are independent systems and omitting either one creates a security gap
- Show a loading skeleton grid on first render instead of a spinner — skeletons reduce perceived wait time and prevent layout shift when the real grid appears
- Add a delete confirmation dialog before removing any media item — accidental deletion of photos is one of the most common user complaints in early app reviews
- Test upload and video playback on a real mobile device from the start — camera permissions, file pickers, and video rendering all behave differently than desktop browser simulations
- Set signed URL expiry to 86400 seconds for cached images — short expiry causes broken images in FlutterFlow and any caching layer

## Frequently asked questions

### Can I let users upload both photos and videos in the same gallery?

Yes. Accept image/jpeg, image/png, and video/mp4 in your MIME type allowlist and store the mime_type column in media_items. Your UI then conditionally renders an image component or a video player based on that value. Specify all three MIME types explicitly in your prompt — AI tools default to images only.

### How do I stop users from seeing each other's media?

You need two layers of access control: Row Level Security on the media_items table (policy: auth.uid() = user_id) AND a Private Storage bucket with bucket-level policies for the same condition. Table RLS alone does not protect the raw file URLs in Storage — both systems must be configured.

### What file size limit should I set?

10 MB per file is a safe default that covers most phone photos and short video clips while keeping storage costs predictable. For video-heavy apps raise it to 50–100 MB but add server-side validation in a Supabase Edge Function — client-side validation alone can be bypassed.

### Does this work offline — can users view cached photos without internet?

By default no — images are fetched from Supabase Storage URLs. FlutterFlow's CachedNetworkImage stores images in device cache, so recently viewed photos load offline. For full offline support (browsing all owned media without a connection) you need a custom local database sync, which is beyond what AI tools generate out of the box.

### How do I add captions or descriptions to media items?

The media_items table includes a caption text column. In the UI, add a text input below the upload button (or in the lightbox view) that writes to that column via a Supabase update call. Mention caption editing explicitly in your prompt — otherwise the AI generates the column but no UI to edit it.

### Can I add search or filter to the gallery?

Yes — filtering by mime_type (photos vs videos), date range, or keyword search on caption are all Supabase query parameters. Add a filter bar above the grid and pass the selected filters to your Supabase .select().eq().ilike() chain. For full-text search across captions, enable the pg_trgm extension in Supabase and add a GIN index on the caption column.

### How much storage will 1,000 active users consume?

Assuming each user uploads an average of 20 items at ~500 KB each (compressed phone photos), that is 10 GB — costing roughly $0.21/month in Supabase Storage fees. Video uploads change this significantly: 20 short videos at 5 MB each would be 100 GB, or about $2.10/month. Enforce file size limits early to keep this number predictable.

### Can I generate automatic thumbnails for videos?

Supabase Storage's image transformation API only works on images, not video files. For video thumbnails, trigger a Supabase Edge Function on upload that uses ffmpeg (via a Deno subprocess or an external worker) to extract a frame at 1 second, uploads it as a JPEG to Storage, and writes the thumbnail_url back to the media_items row. Specify this in your prompt if you want AI tools to scaffold the Edge Function.

---

Source: https://www.rapidevelopers.com/app-features/media-gallery
© RapidDev — https://www.rapidevelopers.com/app-features/media-gallery
