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

- Tool: App Features
- Last updated: July 2026

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

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

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

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

```sql
create table public.user_files (
  id uuid primary key default gen_random_uuid(),
  user_id uuid references auth.users(id) on delete cascade not null,
  filename text not null,
  storage_path text not null unique,
  size_bytes bigint not null,
  mime_type text,
  folder text not null default 'root',
  tags text[] not null default '{}',
  is_public boolean not null default false,
  signed_url_expires_at timestamptz,
  created_at timestamptz not null default now()
);

alter table public.user_files enable row level security;

-- Users can manage their own files
create policy "Users manage own files"
  on public.user_files for all
  using (auth.uid() = user_id)
  with check (auth.uid() = user_id);

-- Public files are readable by anyone (for shared links)
create policy "Public files are readable by anyone"
  on public.user_files for select
  using (is_public = true);

create index user_files_user_created_idx
  on public.user_files (user_id, created_at desc);

create index user_files_folder_idx
  on public.user_files (user_id, folder);

create index user_files_tags_idx
  on public.user_files using gin (tags);
```

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 paths

### Lovable — fit 5/10, 3-5 hours

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.

1. Create 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. Paste 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. Test upload with a PDF and an image; confirm both appear in the file list with correct file size and relative timestamp
4. Test 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. Test delete: select a file, click delete, confirm the dialog, and verify the file disappears from the list and from the Supabase Storage bucket
6. Test the shareable link: click Share on a file, copy the URL, open it in an incognito window; it should download without requiring login

Starter prompt:

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

Limitations:

- 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

### V0 — fit 4/10, 4-6 hours

V0 generates clean Next.js Server Actions for Supabase Storage uploads and handles signed URL generation in Route Handlers — the right choice when the file manager is part of a larger Next.js app you're actively building.

1. Prompt V0 with the spec below; it will generate the FileUpload component, FileList component, and the /api/signed-url Route Handler for private file access
2. Add Supabase environment variables in the Vars panel: NEXT_PUBLIC_SUPABASE_URL, NEXT_PUBLIC_SUPABASE_ANON_KEY, and SUPABASE_SERVICE_ROLE_KEY for the signed URL Route Handler
3. In the Supabase Dashboard, create an 'uploads' bucket — set to private (not public) if files should be accessible only via signed URLs
4. Run the user_files SQL schema from this page in the Supabase SQL editor
5. If the react-pdf preview shows a blank white box, add a follow-up: 'Import pdfjs from react-pdf and set GlobalWorkerOptions.workerSrc to new URL("pdfjs-dist/build/pdf.worker.min.mjs", import.meta.url).toString() at the top of the PDF preview component'
6. Test that bulk delete uses Promise.allSettled() — in the V0 preview, select 3 files and delete; confirm all 3 disappear and no partial-failure silent behavior occurs

Starter prompt:

```
Build a file upload and management feature in Next.js App Router with Supabase and shadcn/ui. FileUpload client component: react-dropzone accepting { 'image/*': [], 'application/pdf': [], '*/*': [] } with maxSize 10485760 (10MB). Multiple file upload supported. Show isDragActive highlight. Rejected files list below dropzone with rejection reason. For each accepted file, show a progress bar during upload: upload to Supabase Storage at path `uploads/{userId}/{timestamp}-{filename}` using the supabase-js client upload() with onUploadProgress callback tracking percentage per file in a Map state. After upload resolves, insert to Supabase `user_files` table (filename, storage_path, size_bytes, mime_type, user_id, created_at). Wrap in try-catch: if the insert fails, call supabase.storage.from('uploads').remove([storagePath]) to delete the orphaned file, then show error toast. FileList server component: query user_files with supabase anon client scoped to auth.uid(); render in shadcn DataTable (TanStack Table v8) with columns: filename, size (format to MB/KB), mime type icon, relative date. Search input for filename filter (ILIKE). Sortable columns. Pagination via range() query (20 per page). Bulk select with Checkbox column and batch delete via Promise.allSettled(). Single file delete with AlertDialog confirmation. After delete, remove from user_files table AND from Supabase Storage. Preview modal: images in a Dialog with full-size img tag. PDFs in a Dialog using react-pdf Document+Page components; configure pdfjs.GlobalWorkerOptions.workerSrc at component top level. Non-previewable files show Download button only. Signed URL Route Handler at /api/signed-url: POST accepts { storagePath, userId }; verify session user matches userId; use SUPABASE_SERVICE_ROLE_KEY to call createSignedUrl(storagePath, 604800); return { url, expiresAt }. Share button per file calls this route, shows the URL with expiry date, copies to clipboard. UI states: empty state with upload prompt, per-file progress skeleton, error toast with retry, success toast, delete confirmation AlertDialog.
```

Limitations:

- Supabase Storage bucket must be manually created in the Supabase Dashboard — V0 does not auto-provision buckets
- react-pdf sometimes generates with an esm.sh import that fails in the V0 preview sandbox — if PDF preview is blank, the worker source URL configuration is likely the issue
- NEXT_PUBLIC_SUPABASE_URL and NEXT_PUBLIC_SUPABASE_ANON_KEY must be set manually in the Vercel environment variables before the deployed app can connect to Supabase

### Custom — fit 3/10, 3-5 days

Custom development is justified for enterprise file management requirements that exceed what the react-dropzone + Supabase pattern handles: virus scanning, automatic thumbnail generation, folder hierarchies with team permissions, and audit logging.

1. Add ClamAV-based virus scanning via a Supabase Edge Function that scans the uploaded file before marking it accessible — files are quarantined in a separate bucket until scan completes
2. Build automatic thumbnail generation: a Storage trigger fires a Supabase Edge Function using the sharp library to create 256×256 and 64×64 thumbnails, store them at thumbnail paths, and update the user_files row with thumbnail_url
3. Build a folder hierarchy: expand the user_files table with a parent_folder_id self-referential FK and a materialized path column for efficient nested queries; render as a tree sidebar in the file manager UI
4. Add per-file sharing permissions: a file_shares table linking file_id to email addresses with expiry and download count limits; the signed URL endpoint checks this table before generating the URL

Limitations:

- All of this is achievable with custom dev but most founders don't need it at MVP stage — virus scanning and thumbnail generation are real needs for healthcare and legal use cases, but most file managers launch successfully without them

## Gotchas

- **Upload succeeds but file never appears in the file list** — 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** — 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** — 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** — 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

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

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

---

Source: https://www.rapidevelopers.com/app-features/file-upload-and-management
© RapidDev — https://www.rapidevelopers.com/app-features/file-upload-and-management
