Skip to main content
RapidDev - Software Development Agency
App Featuresforms-data23 min read

How to Add File Upload and Management to Your App (Copy-Paste Prompts Included)

File upload and management needs a drag-and-drop zone (react-dropzone), a progress tracker wired to Supabase Storage, a metadata table (user_files) tracking file names and paths, and a file list view with preview and delete. With Lovable or V0 you can ship a working file manager in 3–6 hours for $0/month on the free tier. Signed URL expiry and the orphaned-file-on-metadata-insert-failure are the two gotchas that break nearly every first build.

4.9Clutch rating
600+Happy partners
17+Countries served
190+Team members

Feature spec

Beginner

Category

forms-data

Build with AI

3-6 hours with Lovable or V0 + Supabase

Custom build

3-5 days custom dev

Running cost

$0/mo up to 100 users; $25/mo at 1K users; $50-100/mo at 10K users

Works on

Web

Everything it takes to ship File Upload and Management — parts, prompts, and real costs.

TL;DR

File upload and management needs a drag-and-drop zone (react-dropzone), a progress tracker wired to Supabase Storage, a metadata table (user_files) tracking file names and paths, and a file list view with preview and delete. With Lovable or V0 you can ship a working file manager in 3–6 hours for $0/month on the free tier. Signed URL expiry and the orphaned-file-on-metadata-insert-failure are the two gotchas that break nearly every first build.

What File Upload and Management Actually Is

File upload and management is the layer that lets users bring their own files into your app — documents, images, attachments — and then find, preview, share, and delete them later. The upload side is a drag-and-drop zone that validates file type and size, shows a progress bar per file, and writes to Supabase Storage. The management side is a file list with sort and search, in-app preview for images and PDFs, and a shareable download link with optional expiry. The product decisions that shape the build: whether files are private (signed URLs, user-only access) or public (direct bucket URL), whether folder organization is needed at launch, whether there is a storage quota per user, and whether you need bulk upload or single-file only. These decisions determine the RLS policy structure more than anything else.

What users consider table stakes in 2026

  • Drag-and-drop upload zone with a click-to-browse fallback — the drag overlay should highlight clearly when a file is dragged over the zone
  • Per-file upload progress bar showing percentage and current file name, with a retry button on failed uploads
  • File type and size validation with inline error messages before the upload starts — not after a network round-trip
  • File list view showing name, size (human-readable: '2.4 MB'), upload date, and a delete button with confirmation dialog
  • In-app preview for images (full-size lightbox) and PDFs (react-pdf inline viewer) without requiring the user to download first
  • Shareable download link with visible expiry date and a Regenerate Link button for expired links

Anatomy of the Feature

Six components. The dropzone and file list are single-prompt territory. The signed URL generator and the orphan-prevention pattern in the upload flow are where first builds most commonly fail.

Layers:UIDataBackend

Upload dropzone

UI

react-dropzone with an accept prop restricting MIME types (e.g., { 'image/*': [], 'application/pdf': [] }) and a maxSize prop (e.g., 10 * 1024 * 1024 for 10MB). Shows a drag-over highlight state via the isDragActive boolean. Supports multiple={true} for batch upload. Rejected files display in a list below the zone with the rejection reason ('File too large — max 10MB', 'File type not allowed — PDF and images only').

Note: The accept prop uses MIME types as keys, not file extensions. Accept PDF with 'application/pdf', not '.pdf'. AI tools sometimes mix these up — if rejected files aren't being caught, check that the accept object uses MIME type keys.

Upload progress tracker

Backend

Supabase Storage upload uses the onUploadProgress callback (backed by XMLHttpRequest) to report bytes uploaded vs total. The progress percentage is stored in local React state as a Map keyed by file name (e.g., { 'report.pdf': 67, 'photo.jpg': 100 }). Concurrent uploads are managed with Promise.allSettled() so one file failure doesn't block others. Failed uploads show a retry button that re-triggers the upload for that specific file.

Note: Use Promise.allSettled() rather than Promise.all() for batch uploads — Promise.all() cancels all concurrent uploads when one fails, which is not the right behavior for a multi-file uploader.

File metadata store

Data

A Supabase `user_files` table stores the metadata for every uploaded file: id, user_id, storage_path (the Supabase Storage key), filename, size_bytes, mime_type, folder, tags, is_public, and created_at. The storage_path is used to generate public or signed URLs on read. Never store the full URL — Storage bucket names and endpoints change when projects are migrated; the path is stable.

Note: If the Storage upload succeeds but the user_files insert fails (RLS mismatch, network error, constraint violation), the file exists in the bucket as an orphan with no metadata record. The cleanup strategy matters — either delete the orphaned file on insert failure or run a periodic cleanup job.

File list view

UI

A shadcn/ui DataTable using TanStack Table v8 renders the user's files from a Supabase .select() on user_files filtered by auth.uid(). Columns: filename, size_bytes (formatted to human-readable), mime_type (shown as an icon), created_at (relative time: '3 hours ago'), and action buttons (preview, share, delete). Sortable by any column. Search filter input that queries filename ILIKE '%term%'. Bulk select with checkboxes for batch delete. Pagination via Supabase's range() query.

Note: Generate signed URLs lazily on preview/share button click — not in the list query. Generating hundreds of signed URLs in the list view adds significant latency and Supabase API calls.

In-app preview

UI

react-pdf (a PDF.js wrapper) renders PDF files in a modal with page navigation. Images render in a lightbox using a full-screen modal with an <img> tag and lazy loading. Video files use react-player. Files with unsupported MIME types fall back to a Download to view link. The preview component reads the file URL from a generated signed URL (private files) or a public bucket URL (public files).

Note: react-pdf requires the PDF.js worker file to be configured via pdfjs.GlobalWorkerOptions.workerSrc before any Document component renders. Skipping this configuration results in a blank white box with no error in the console.

Signed URL generator

Backend

A Supabase Edge Function (or Next.js Route Handler) calls supabase.storage.from('uploads').createSignedUrl(storagePath, expirySeconds) and returns the signed URL. Default expiry for sharing links: 7 days (604,800 seconds). The Edge Function verifies that the requesting user owns the file (auth.uid() = user_id) before generating the URL. The expiry timestamp is stored in user_files.signed_url_expires_at for the Regenerate Link button.

Note: Never generate signed URLs in a direct client-side call using the anon key for private files — the anon key cannot access private buckets. The URL generation must go through a server-side function that uses the service role key.

The data model

One table stores file metadata; Supabase Storage holds the files. RLS on the table plus Supabase Storage bucket policies work together to enforce access control. Run this in the Supabase SQL editor.

schema.sql
1create table public.user_files (
2 id uuid primary key default gen_random_uuid(),
3 user_id uuid references auth.users(id) on delete cascade not null,
4 filename text not null,
5 storage_path text not null unique,
6 size_bytes bigint not null,
7 mime_type text,
8 folder text not null default 'root',
9 tags text[] not null default '{}',
10 is_public boolean not null default false,
11 signed_url_expires_at timestamptz,
12 created_at timestamptz not null default now()
13);
14
15alter table public.user_files enable row level security;
16
17-- Users can manage their own files
18create policy "Users manage own files"
19 on public.user_files for all
20 using (auth.uid() = user_id)
21 with check (auth.uid() = user_id);
22
23-- Public files are readable by anyone (for shared links)
24create policy "Public files are readable by anyone"
25 on public.user_files for select
26 using (is_public = true);
27
28create index user_files_user_created_idx
29 on public.user_files (user_id, created_at desc);
30
31create index user_files_folder_idx
32 on public.user_files (user_id, folder);
33
34create index user_files_tags_idx
35 on public.user_files using gin (tags);

Heads up: The GIN index on tags enables efficient `WHERE tags @> ARRAY['invoice']` queries for tag-based filtering. The storage_path UNIQUE constraint prevents duplicate path entries if a file with the same name is re-uploaded — decide whether to overwrite (upsert on storage_path) or rename (append a timestamp suffix to the filename) before implementing.

Build it — pick your path

Each tool fits this feature differently. Switch tabs to compare fit, time, the exact steps, and a copy-paste prompt.

Full-stack, non-technical friendlyFit for this feature:

The fastest all-in-one path: Lovable's Supabase Storage connector handles bucket creation and upload wiring; react-dropzone is native to its stack; the file list with delete is a standard single-prompt scaffold.

Step by step

  1. 1Create a new Lovable project, connect Lovable Cloud, then open the Cloud tab and navigate to Storage — verify that Lovable creates an 'uploads' bucket automatically after the first prompt, or create it manually if not
  2. 2Paste the prompt below in Agent Mode; Lovable will generate the upload dropzone, progress tracker, user_files table with RLS, file list, and delete flow
  3. 3Test upload with a PDF and an image; confirm both appear in the file list with correct file size and relative timestamp
  4. 4Test the PDF preview: open the preview modal for the PDF and confirm react-pdf renders the first page — if you see a blank white box, add a follow-up prompt: 'Configure pdfjs.GlobalWorkerOptions.workerSrc to point to the PDF.js worker file from pdfjs-dist/build/pdf.worker.min.mjs'
  5. 5Test delete: select a file, click delete, confirm the dialog, and verify the file disappears from the list and from the Supabase Storage bucket
  6. 6Test the shareable link: click Share on a file, copy the URL, open it in an incognito window; it should download without requiring login
Paste into Lovable
Build a file upload and management feature with Supabase Storage. Create a Supabase table `user_files` (id UUID, user_id UUID references auth.users, filename TEXT NOT NULL, storage_path TEXT NOT NULL UNIQUE, size_bytes BIGINT, mime_type TEXT, folder TEXT DEFAULT 'root', tags TEXT[] DEFAULT '{}', is_public BOOLEAN DEFAULT false, created_at TIMESTAMPTZ). RLS: users manage own files (all operations WHERE auth.uid() = user_id); public files readable by anyone (SELECT WHERE is_public = true). Upload area: react-dropzone accepting image/jpeg, image/png, image/webp, application/pdf, and any other file types. Max file size 10MB per file. Multiple={true} for batch upload. Show drag-over highlight when file is dragged over the zone. Show rejected files below the zone with clear reason ('File too large — max 10MB', 'File type not accepted'). For each uploading file, show a progress bar with filename and percentage. Upload to Supabase Storage bucket 'uploads' at path `{user_id}/{filename}`. After upload, insert a row to `user_files` with the storage_path and file metadata. If the insert fails, delete the just-uploaded file from Storage to prevent orphans. Wrap Storage upload + metadata insert in a try-catch and show error toast if either fails. File list: TanStack Table v8 DataTable showing filename, size (human-readable: '2.4 MB'), mime_type icon, relative timestamp. Sortable by all columns. Search filter on filename. Bulk select checkboxes for batch delete. Pagination (20 files per page). Preview: image files open in a full-screen lightbox modal. PDF files open in a react-pdf modal with page navigation (configure pdfjs.GlobalWorkerOptions.workerSrc = new URL('pdfjs-dist/build/pdf.worker.min.mjs', import.meta.url).toString()). Delete: single file delete button with a confirmation dialog ('Delete report.pdf? This cannot be undone.'). Batch delete for selected files using Promise.allSettled(). After delete, remove from Storage AND from user_files table. Shareable link: Share button per file generates a Supabase signed URL with 7-day expiry and copies it to clipboard. Show expiry date next to the copied URL. UI states: empty state ('Upload your first file — drag it here or click to browse'), uploading skeleton while files upload, upload error with retry button, success toast 'File uploaded successfully', delete confirmation dialog, empty search results state.

Where this path bites

  • react-pdf preview requires the PDF.js worker configuration step — if this is missing from the initial build, the preview shows a blank white box with no error
  • Signed URL generation for private files requires the Supabase service role key — if Lovable generates the URL using the anon key, it will fail for private bucket files; this needs an Edge Function
  • Bulk delete with checkbox selection sometimes requires a follow-up prompt to wire the selection state correctly with Promise.allSettled()
  • Large file uploads over 50MB may time out in the Lovable preview environment — test on the published URL for large files

Third-party services you'll need

The core upload and management stack runs on free libraries. Storage is the only variable cost that scales with usage.

ServiceWhat it doesFree tierPaid from
Supabase StorageFile storage with RLS-aware bucket policies and signed URL generation for private files1GB Storage, 2GB egress freePro $25/mo (100GB Storage); egress $0.09/GB above free allowance (approx)
react-dropzoneDrag-and-drop upload zone with MIME type filtering, file size limits, and rejection handlingFully free (MIT)Free, open-source
react-pdfIn-browser PDF preview rendering via PDF.js — no server-side rendering neededFully free (MIT)Free, open-source
sharp (optional, Edge Function)Server-side thumbnail generation and image resizing for file previews at multiple sizesFully free (Apache 2.0)Free, open-source

Swipe the table sideways to see pricing.

What it costs to run

Drag through the tiers to see how your monthly bill scales with users — no surprises later.

Estimated monthly running cost

$0/mo

Supabase free tier (1GB Storage) covers typical usage at 100 users — if each user stores 10 files averaging 500KB, that is 500MB total, comfortably within the free tier. All UI libraries are free.

Estimates use verified 2026 pricing and assume typical usage per user. Your real bill depends on activity, storage, and third-party plans.

What breaks when AI tools build this

The failures people actually hit on their first build — the symptom, why it happens, and the exact fix.

Upload succeeds but file never appears in the file list

Symptom: File upload to Supabase Storage succeeds (the file exists in the bucket), but the INSERT to `user_files` fails — either due to an RLS policy mismatch (the INSERT policy requires auth.uid() = user_id but the user_id wasn't passed), a network interruption between the upload and the insert, or a constraint violation. The file exists in Storage with no metadata record. On the next page load, the query on user_files returns zero rows for that file — it never appears in the list. This is called an orphaned file.

Fix: Wrap the Storage upload and user_files INSERT in a try-catch block. If the INSERT fails, immediately call supabase.storage.from('uploads').remove([storagePath]) to delete the orphaned file. Show an error toast to the user: 'Upload failed — please try again.' Alternatively, use a Supabase Edge Function that handles both the Storage upload and the metadata insert atomically, returning success only when both operations complete.

react-pdf shows a blank white box in the preview modal

Symptom: react-pdf (and the underlying PDF.js library) requires a worker JavaScript file to be loaded for the actual PDF parsing. When V0 or Lovable imports react-pdf without configuring pdfjs.GlobalWorkerOptions.workerSrc, the worker fails to initialize silently. The Document component renders but the Page component produces nothing — a white box with the correct dimensions but no content. No error appears in the browser console.

Fix: In the component that imports react-pdf, add this configuration before any Document component renders: `import { pdfjs } from 'react-pdf'; pdfjs.GlobalWorkerOptions.workerSrc = new URL('pdfjs-dist/build/pdf.worker.min.mjs', import.meta.url).toString();`. This must be at the module level, not inside a useEffect or component body. Confirm pdfjs-dist is listed in your package.json dependencies.

Signed URL expires while the user is actively sharing it

Symptom: The default Supabase createSignedUrl() expiry is 3600 seconds (1 hour). A user copies the signed URL and emails it to a colleague. The colleague opens the email the next morning — 14 hours later — and gets a 403 Access Forbidden error. The link appears broken; the user has to generate a new one. For links that are meant for sharing, 1-hour expiry is too short for nearly all real use cases.

Fix: Set signed URL expiry to 7 days (604,800 seconds) for links generated via the Share button. Display the expiry date next to the copied URL: 'Link valid until July 18'. Store the expiry timestamp in user_files.signed_url_expires_at. Add a Regenerate Link button in the file manager that checks if the link is within 24 hours of expiry and, if so, calls the signed URL endpoint to generate a fresh URL for the same file — without requiring the user to understand what a signed URL is.

Bulk delete partially fails silently

Symptom: When a user selects 20 files and clicks Delete All, the UI sends 20 parallel delete operations. If Promise.all() is used and file #7 fails (bucket permissions issue, file already deleted in another session, or network error), Promise.all() rejects immediately — but the remaining 13 files after #7 were never attempted. Worse, the UI may show 'All files deleted' while 14 files remain. If Promise.allSettled() is used but without checking the results, failed deletes are silently ignored.

Fix: Use Promise.allSettled() for all batch delete operations. After settlement, check the results array for rejected promises: `const failed = results.filter(r => r.status === 'rejected')`. If any operations failed, show a summary error: '1 file could not be deleted — try again or refresh the page.' Then refresh the file list from Supabase to show the true current state rather than optimistically removing all selected items from local state.

Best practices

1

Always run the orphan-prevention pattern: wrap the Storage upload and user_files INSERT in a try-catch; delete the uploaded file from Storage if the INSERT fails — a file without a metadata record is worse than a failed upload

2

Never store full Storage URLs in the database — store only the storage_path and generate public or signed URLs at read time; URLs change when projects are migrated, but paths are stable

3

Generate signed URLs lazily on preview or share button click, not in the file list query — generating URLs for 50 files in the list adds 50 API calls per page load

4

Set signed URL expiry to 7 days minimum for sharing links, not the default 1 hour — display the expiry date next to every shared URL so users know when to regenerate

5

Use Promise.allSettled() for every batch operation (bulk upload, bulk delete) and check the results for partial failures — never show success if any operation in the batch failed

6

Configure the pdfjs.GlobalWorkerOptions.workerSrc at the module level in any component that uses react-pdf — missing this configuration causes a silent blank PDF preview that is hard to debug

7

Show a delete confirmation dialog with the exact filename: 'Delete report.pdf? This cannot be undone.' Vague confirmations ('Delete this file?') lead to accidental deletes of the wrong file when users are in a hurry

When You Need Custom Development

The react-dropzone + Supabase pattern delivers a solid file manager for most apps. These requirements indicate the feature has grown beyond that scope:

  • Virus scanning required before files are accessible to any user — healthcare, legal, and fintech apps often require ClamAV or a commercial scanning API (Metadefender, VirusTotal) as a processing gate
  • Automatic content moderation: NSFW image detection via Google Cloud Vision or AWS Rekognition before files become visible to other users in shared workspaces
  • File versioning with a history panel: keep previous versions of a document with a diff view, version restoration, and an audit log of who changed what and when
  • Enterprise permissions: per-file and per-folder access control across teams and roles — not just owner-only access, but viewer/editor/admin roles per file with invitation flows

RapidDev builds these features for production

Real auth, database, and payments — fixed price, delivered in weeks. You own 100% of the code.

Get a fixed-price quote

Frequently asked questions

How do I add drag-and-drop file upload to my web app?

Install react-dropzone and add the useDropzone() hook to your upload component. The key props: accept (restrict MIME types), maxSize (reject oversized files), and multiple (allow batch upload). The hook returns getRootProps() and getInputProps() that you spread onto your drop zone div and hidden input respectively. In the onDrop callback, loop through the accepted files and call supabase.storage.from('uploads').upload(path, file) for each one, tracking progress via the onUploadProgress callback.

Should I use public or private Supabase Storage buckets for user uploads?

Private buckets for user-uploaded files in almost every case. Public buckets make every file URL guessable if you know the storage path — anyone with the URL can download the file with no authentication. Private buckets require a signed URL to access, which you control (expiry, revocation). Use public buckets only for deliberately public assets like marketing images or user avatars where sharing is the primary use case. Set the bucket policy in the Supabase Storage Dashboard, not just the table RLS.

How do I create shareable download links that expire?

Call supabase.storage.from('uploads').createSignedUrl(storagePath, 604800) — the second parameter is expiry in seconds (604,800 = 7 days). This must be called server-side using the Supabase service role key in a Route Handler or Edge Function, not client-side with the anon key. Return the signed URL and the expiry timestamp to the client. Display the expiry date next to the copied URL ('Valid until July 18') and offer a Regenerate Link button that calls the same endpoint to issue a fresh URL.

How do I show a preview of an uploaded PDF in the browser?

Install react-pdf and pdfjs-dist. At the top of the preview component (module level, not inside a function), add: `import { pdfjs } from 'react-pdf'; pdfjs.GlobalWorkerOptions.workerSrc = new URL('pdfjs-dist/build/pdf.worker.min.mjs', import.meta.url).toString()`. Then render a Document component with the file URL as the file prop, and a Page component inside it for each page. Add page navigation buttons that increment/decrement a pageNumber state. This is the most common gotcha for this feature — the blank white box is almost always a missing workerSrc configuration.

What's the best way to store file metadata alongside the file in Supabase?

Create a user_files table with id, user_id, storage_path, filename, size_bytes, mime_type, and created_at. Store only the storage_path (the Supabase Storage key like 'uploads/user-id/filename.pdf') — never the full URL. Generate public or signed URLs at read time using supabase.storage.from('uploads').getPublicUrl(path) or createSignedUrl(). Full URLs break when projects are migrated; paths are always stable. Write to this table immediately after the Storage upload resolves, inside the same try-catch block.

How do I restrict file uploads to specific types like PDF and images only?

In react-dropzone, set the accept prop: `accept={{ 'image/jpeg': [], 'image/png': [], 'image/webp': [], 'application/pdf': [] }}`. The keys are MIME types (not file extensions). Files that don't match are returned in the fileRejections array with rejection code 'file-invalid-type'. Display these in a list below the drop zone with the message 'file.docx is not allowed — accepted types are PDF, JPEG, PNG, WebP.' Also validate file types server-side in the upload handler — client-side accept restrictions can be bypassed.

How do I handle large file uploads (100MB+) without timing out?

For files over 10MB, switch from the standard Supabase upload() to the resumable upload endpoint using the TUS protocol. The supabase-js client supports this: use supabase.storage.from('uploads').uploadToSignedUrl() with a chunked approach, or use the tus-js-client library directly pointed at your Supabase Storage TUS endpoint. Set chunk size to 6MB (Supabase's recommended chunk size for resumable uploads). On mobile, add a foreground service to keep the upload running when the user switches apps. Show a persistent progress indicator with percentage and estimated time remaining.

Can multiple users share the same uploaded file?

Yes, with two patterns. Simple sharing: update is_public to true on the user_files row — the public SELECT RLS policy then allows any authenticated or anonymous user to read the metadata, and you generate a signed URL for anyone who requests it. Controlled sharing: create a file_shares table linking file_id to a recipient email with an expiry date and a download count limit, and validate against this table in the signed URL endpoint. The simple pattern ships in an AI build; the controlled sharing pattern requires custom development for the permission management UI.

RapidDev

Need this feature production-ready?

RapidDev builds file upload and management into real apps — auth, database, payments — at $13K–$25K.

Book a free consultation

30-min call. No commitment.

Want this built for you?

We ship production apps at a fixed price — $13K–$25K, 6–10 weeks, source code yours. You've seen what it takes; we do it every week.

Get a fixed-price quote

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.