# How to Build a File Sharing App with Lovable

- Tool: How to Build with Lovable
- Difficulty: Intermediate
- Compatibility: Lovable Pro or higher
- Last updated: April 2026

## TL;DR

Build a file sharing app in Lovable using Supabase Storage with RLS, a files metadata table, signed URLs for secure sharing, and drag-and-drop uploads. Users get folder simulation via path prefixes, per-user storage quotas, and shareable links that expire — all without any third-party file hosting service.

## Before you start

- Lovable Pro account for Edge Function and complex upload UI generation
- Supabase project with Storage enabled (free tier: 1GB storage, 50MB file limit)
- Supabase URL and anon key saved as VITE_SUPABASE_URL and VITE_SUPABASE_ANON_KEY
- Supabase service role key saved to Cloud tab → Secrets as SUPABASE_SERVICE_ROLE_KEY
- Basic understanding of how file paths and URLs work

## Step-by-step guide

### 1. Set up Supabase Storage and the files metadata schema

Prompt Lovable to create the private Storage bucket, the files and folders metadata tables, and the RLS policies. The bucket policy and the database RLS policy must both be configured — one controls storage access, the other controls metadata access.

```
Set up a file sharing system in Supabase:

1. Create a PRIVATE Storage bucket named 'user-files' with:
   - Public: false
   - File size limit: 50MB
   - Allowed MIME types: image/*, application/pdf, application/zip, text/*, video/mp4, audio/*

2. Storage bucket RLS policies:
   - SELECT: (auth.uid()::text = (storage.foldername(name))[1])
   - INSERT: (auth.uid()::text = (storage.foldername(name))[1])
   - DELETE: (auth.uid()::text = (storage.foldername(name))[1])

3. Database tables:
   - files: id, user_id, storage_path (text, unique), filename (text), folder_path (text default '/'), size_bytes (bigint), mime_type (text), is_starred (bool default false), created_at, updated_at
   - shared_links: id, file_id, user_id, signed_url (text), token (text), expires_at (timestamptz), is_revoked (bool default false), download_count (int default 0), created_at
   - user_storage: id (references auth.users), total_bytes_used (bigint default 0), quota_bytes (bigint default 1073741824) -- 1GB default

4. RLS on database tables:
   - files: users can SELECT/INSERT/UPDATE/DELETE their own rows (user_id = auth.uid())
   - shared_links: users can SELECT/UPDATE their own rows (user_id = auth.uid()). Service role for INSERT.
   - user_storage: users can SELECT their own row. Service role for INSERT/UPDATE.

5. SQL function update_storage_usage(p_user_id uuid, p_delta bigint) RETURNS void:
   INSERT INTO user_storage (id, total_bytes_used) VALUES (p_user_id, GREATEST(0, p_delta))
   ON CONFLICT (id) DO UPDATE SET total_bytes_used = GREATEST(0, user_storage.total_bytes_used + p_delta)
```

> Pro tip: Ask Lovable to also add a Supabase Database Trigger on files DELETE that calls update_storage_usage(OLD.user_id, -OLD.size_bytes) automatically whenever a file row is deleted. This keeps the quota counter accurate without additional application code.

**Expected result:** The 'user-files' Storage bucket is created as private. The three database tables exist with RLS policies. The update_storage_usage function is ready. Storage bucket policies are in place.

### 2. Build the drag-and-drop file upload component

Create a reusable file upload component that accepts files via drag-and-drop or click-to-browse, checks the user's storage quota before uploading, shows per-file progress bars, and inserts metadata rows after successful uploads.

```
Build a FileUploader component at src/components/FileUploader.tsx.

Requirements:
- A drop zone div with dashed border that:
  - Changes border color and background on dragover
  - Accepts file drops (onDrop event) and click-to-open file input
  - Shows 'Drop files here or click to browse' text with an upload icon
- Props: currentFolder: string (the current folder path), onUploadComplete: () => void
- Multi-file support: handle FileList from both drag and input
- Before uploading, check quota: fetch user_storage for the current user. If total_bytes_used + sum of new file sizes > quota_bytes, show an Alert: 'Not enough storage. You need X MB but only Y MB available.'
- For each file:
  - Generate storage path: {user_id}/{currentFolder}/{filename} (replace spaces with underscores)
  - Call supabase.storage.from('user-files').upload(storagePath, file, { onUploadProgress: (p) => setProgress(p.loaded / p.total * 100) })
  - On success, insert a files row: { user_id, storage_path, filename, folder_path: currentFolder, size_bytes: file.size, mime_type: file.type }
  - Call update_storage_usage(user_id, file.size) RPC
  - Show a per-file Progress bar component
  - Show a green check on completion or red X on failure
- Call onUploadComplete() after all uploads finish
```

> Pro tip: Generate a unique filename to prevent overwrites: prefix each filename with a timestamp and a 4-char random hex string — e.g. 1714000000_a3f2_report.pdf. Store the original filename in the files.filename column for display.

**Expected result:** The FileUploader component accepts files via drag-and-drop. The quota check blocks uploads that would exceed the limit. Each file shows a progress bar. Completed uploads appear immediately in the file list.

### 3. Build the folder browser with breadcrumb navigation

Create the main file browser page with a folder tree, breadcrumb navigation, file list, and contextual action menus. Folders are derived from the folder_path column values, not stored as separate rows.

```
Build the file browser at src/pages/FileBrowser.tsx.

State:
- currentFolder: string (starts as '/', updated by navigation)

Requirements:
- Breadcrumb navigation: split currentFolder by '/' and render each segment as a Button that navigates to that depth. Root shows a Home icon.
- Folder sidebar: derive unique folder paths from the files table WHERE user_id = auth.uid() AND folder_path LIKE currentFolder + '%'. Extract the immediate subdirectory segment after currentFolder. Render as clickable folder Buttons with folder icon.
- File list: fetch files WHERE user_id = auth.uid() AND folder_path = currentFolder ORDER BY created_at DESC
- Each file row in a Table shows: file type icon (image/PDF/zip/text based on mime_type), filename, size (formatted as KB/MB), modified date (relative), a DropdownMenu with actions
- DropdownMenu actions: Download (calls getSignedUrl for 5 min and triggers download), Share (opens Share Dialog), Rename (inline edit), Star (toggle is_starred), Move to folder, Delete
- Top right: 'New Folder' Button that shows an Input to type a folder name and creates the first file in that path (or just updates the currentFolder state since folders are virtual)
- Star filter: a 'Starred' toggle above the file list that filters to is_starred = true across all folders
- Storage usage indicator at the bottom: 'X.X GB of 1 GB used' with a thin Progress bar
```

**Expected result:** The file browser shows files in the current folder with breadcrumb navigation. Clicking a folder name drills into it. The storage usage bar updates after uploads. The DropdownMenu shows all file actions.

### 4. Build the signed URL sharing Edge Function and Share dialog

Create the Edge Function that generates a signed URL for a file and stores it in shared_links. Build the Share dialog that lets users set an expiry duration and copy the share link.

```
Build two things:

1. Edge Function at supabase/functions/create-share-link/index.ts:
- Accept POST body: { file_id: string, expires_in_hours: number (1|24|168|720) }
- Verify the requesting user owns the file (SELECT from files WHERE id = file_id AND user_id = auth.uid() using the user's JWT)
- Calculate expiresInSeconds = expires_in_hours * 3600
- Call supabase.storage.from('user-files').createSignedUrl(file.storage_path, expiresInSeconds) using service role client
- Insert a shared_links row: { file_id, user_id, signed_url: data.signedUrl, token: data.token, expires_at: new Date(Date.now() + expiresInSeconds * 1000).toISOString() }
- Return { share_url: data.signedUrl, expires_at }

2. Share Dialog component at src/components/ShareDialog.tsx:
- Props: fileId: string, filename: string
- A Select for expiry: '1 hour', '24 hours', '7 days', '30 days'
- A 'Generate Link' Button that calls the create-share-link Edge Function
- After generation, shows the signed URL in a read-only Input with a Copy button
- Shows expiry time as 'Link expires: [relative time]'
- A 'Manage all shares' link that navigates to the Shared Links page
```

**Expected result:** Clicking Share on a file opens the dialog. Selecting expiry duration and clicking Generate Link returns a signed URL. Copying the URL and opening it in a private browser window serves the file directly.

### 5. Build the shared links management page

Create a page listing all the user's active shared links with their file names, expiry times, and download counts. Users can revoke links before they expire.

```
Build a Shared Links page at src/pages/SharedLinks.tsx.

Requirements:
- Fetch all rows from shared_links WHERE user_id = auth.uid() JOIN files ON file_id = files.id ORDER BY created_at DESC
- Show links in a Table with columns:
  - File icon + filename (from joined files row)
  - Shared link (truncated URL with Copy button)
  - Downloads (download_count integer)
  - Expires (relative time, red text if less than 1 hour remaining, 'Expired' badge if past expires_at)
  - Status (Active badge in green, Revoked in gray, Expired in red)
  - Revoke button (only for active, non-expired links) with AlertDialog confirmation
- Revoking sets is_revoked = true on the shared_links row
- Group links in two sections: 'Active' (not revoked, not expired) and 'Expired / Revoked'
- Show a count of active shares and a note that shares expire automatically
```

**Expected result:** The Shared Links page shows all shares grouped by status. Expiry countdown shows in red for soon-to-expire links. Revoking a link moves it to the Expired section immediately.

## Complete code example

File: `supabase/functions/create-share-link/index.ts`

```typescript
import { serve } from 'https://deno.land/std@0.168.0/http/server.ts'
import { createClient } from 'https://esm.sh/@supabase/supabase-js@2'

const corsHeaders = {
  'Access-Control-Allow-Origin': '*',
  'Access-Control-Allow-Headers': 'authorization, x-client-info, apikey, content-type',
  'Content-Type': 'application/json',
}

serve(async (req: Request) => {
  if (req.method === 'OPTIONS') {
    return new Response('ok', { headers: corsHeaders })
  }

  const authHeader = req.headers.get('Authorization')
  if (!authHeader) {
    return new Response(JSON.stringify({ error: 'Unauthorized' }), { status: 401, headers: corsHeaders })
  }

  // User-scoped client to verify ownership
  const userClient = createClient(
    Deno.env.get('SUPABASE_URL') ?? '',
    Deno.env.get('SUPABASE_ANON_KEY') ?? '',
    { global: { headers: { Authorization: authHeader } } }
  )

  const { data: { user } } = await userClient.auth.getUser()
  if (!user) {
    return new Response(JSON.stringify({ error: 'Invalid token' }), { status: 401, headers: corsHeaders })
  }

  const { file_id, expires_in_hours } = await req.json()
  const expiresInSeconds = Math.min((expires_in_hours ?? 24) * 3600, 30 * 24 * 3600)

  // Verify ownership
  const { data: file, error: fileError } = await userClient
    .from('files')
    .select('id, storage_path, filename')
    .eq('id', file_id)
    .eq('user_id', user.id)
    .single()

  if (fileError || !file) {
    return new Response(JSON.stringify({ error: 'File not found' }), { status: 404, headers: corsHeaders })
  }

  // Service role client for storage operation
  const adminClient = createClient(
    Deno.env.get('SUPABASE_URL') ?? '',
    Deno.env.get('SUPABASE_SERVICE_ROLE_KEY') ?? ''
  )

  const { data, error } = await adminClient.storage
    .from('user-files')
    .createSignedUrl(file.storage_path, expiresInSeconds)

  if (error || !data) {
    return new Response(JSON.stringify({ error: 'Failed to generate link' }), { status: 500, headers: corsHeaders })
  }

  const expiresAt = new Date(Date.now() + expiresInSeconds * 1000).toISOString()

  await adminClient.from('shared_links').insert({
    file_id: file.id,
    user_id: user.id,
    signed_url: data.signedUrl,
    expires_at: expiresAt,
  })

  return new Response(
    JSON.stringify({ share_url: data.signedUrl, expires_at: expiresAt }),
    { headers: corsHeaders }
  )
})
```

## Common mistakes

- **Using a public Storage bucket for user files** — A public bucket makes all files accessible to anyone who knows or guesses the storage path. User files are private by definition — using a public bucket exposes all user data. Fix: Create the bucket as Private (public: false). Use signed URLs for all access: for downloads, generate a signed URL with supabase.storage.from('user-files').createSignedUrl(path, 300). Never expose the storage_path directly to the frontend.
- **Not scoping the storage path with user_id as the first folder segment** — Without user_id as the root segment, the RLS policy (storage.foldername(name))[1] = auth.uid() cannot restrict users from accessing each other's files. Fix: Always store files at {user_id}/{folder}/{filename}. The first path segment must always be the auth.uid() string. Generate the storage path in the upload component: const storagePath = `${user.id}/${currentFolder}/${uniqueFilename}`.
- **Updating storage quota with a read-then-write instead of an atomic SQL update** — If two uploads happen simultaneously, both reads see the same current value, both adds to it, and one write overwrites the other — the quota counter under-counts. Fix: Use the update_storage_usage SQL function that does the increment atomically in the database: UPDATE user_storage SET total_bytes_used = total_bytes_used + p_delta. This is safe under concurrent uploads.
- **Storing signed URLs in the database long-term without tracking revocation** — Signed URLs are time-limited but the URL string itself is valid until expiry. If you do not track revocations, you cannot stop sharing a file before the link expires. Fix: Add is_revoked to shared_links and check this flag in the Edge Function (or a proxy) before serving the download. Note: Supabase signed URLs cannot be truly revoked server-side once generated — the is_revoked flag only prevents your app from displaying the URL, not from direct access.

## Best practices

- Always prefix stored filenames with a timestamp or UUID to prevent collisions and path traversal issues. Never use the original filename as the storage path directly.
- Set explicit MIME type restrictions on the Storage bucket to prevent executable file uploads. Reject application/octet-stream, application/x-executable, and text/html to block script injection via file upload.
- Show file size in human-readable form (KB, MB, GB) using a utility function. Store size_bytes as a bigint in the database for accurate arithmetic in quota calculations.
- Add a maximum file size validation on the frontend before starting the upload: if (file.size > 50 * 1024 * 1024) { show error }. This provides instant feedback without wasting bandwidth.
- Clean up Storage bucket files when you delete a files database row. Add a cascade: after the files DELETE, call supabase.storage.from('user-files').remove([storage_path]) in a Supabase Database Function or in the application delete handler.
- Use Supabase Storage's built-in image transformation API for thumbnails: append ?width=200&height=200&resize=cover to signed image URLs. This avoids generating and storing separate thumbnail files.
- Display a friendly file size quota bar using: (total_bytes_used / quota_bytes * 100).toFixed(1) + '%'. Turn the bar red when usage exceeds 80% and show a warning prompt before uploads that would exceed the limit.

## Frequently asked questions

### What is the difference between a public and a private Supabase Storage bucket?

A public bucket serves all files as publicly accessible URLs — no authentication required. Anyone who knows the URL can access the file. A private bucket requires a signed URL or service role access for every file request. For user-uploaded private documents, always use a private bucket. Only use public buckets for assets that are intentionally public, like profile pictures or public media libraries.

### How long do signed URLs last?

You set the duration when generating the signed URL — from 1 second to the maximum your Supabase plan allows. Common choices are 5 minutes for direct downloads (short-lived, prevents link sharing), 24 hours for email attachments, and 7 days for shared links. Once expired, the URL returns a 400 error. To let users re-download, generate a new signed URL.

### Can I increase the 1GB free storage limit?

Supabase Free tier includes 1GB of Storage. Supabase Pro ($25/month) includes 100GB. You can also increase the per-user quota in your user_storage.quota_bytes column — set it per user based on their subscription tier in your app. A paid user might get 10GB (10737418240 bytes) while free users get 1GB.

### What file types should I block for security?

Block all executable file types: .exe, .sh, .bat, .ps1, .com. Also block .html and .svg files that can contain JavaScript and be served in a browser context. The allowed MIME types list in the Storage bucket configuration is your first line of defense. Add a secondary check in the upload component that validates the file extension against an allowlist before uploading.

### How do I implement a download without showing the signed URL to the user?

Proxy the download through an Edge Function: call create-share-link from the frontend, but instead of returning the signed URL to the client, have the Edge Function fetch the file from storage and stream it back with Content-Disposition: attachment headers. This prevents the signed URL from appearing in browser history. The trade-off is higher Edge Function bandwidth usage.

### Can I share files with specific users instead of anyone with the link?

Yes. Add a recipient_email column to shared_links and verify the recipient's email in the Edge Function before serving the file. When someone opens the share link, require them to log in and verify that their email matches the recipient. This creates access-controlled sharing rather than anyone-with-a-link access.

### Is there help available to build a more advanced file sharing or document management system?

Yes. RapidDev builds production file management systems including team workspaces, fine-grained permissions, OCR search, and compliance audit logs. Reach out if your use case requires features beyond what this starter covers.

### Does Supabase Storage support resumable uploads for large files?

Yes. The Supabase Storage client supports the TUS protocol for resumable uploads. Use supabase.storage.from('bucket').upload(path, file, { upsert: false }) for standard uploads up to 5GB. For files larger than 50MB, the TUS resumable upload automatically retries failed chunks. The Lovable upload component generates standard uploads — for large file support, ask Lovable to switch to the TUS upload method.

---

Source: https://www.rapidevelopers.com/how-to-build-lovable/file-sharing-app
© RapidDev — https://www.rapidevelopers.com/how-to-build-lovable/file-sharing-app
