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.
What you're building
Supabase Storage uses the same RLS policy system as your database. Each file is stored under a path like user_id/folder/filename.ext and an RLS policy ensures that only the owner can read or delete their files. Public buckets make all files accessible by URL; private buckets require signed URLs — this app uses a private bucket so files are never publicly accessible without explicit sharing.
Folders in Supabase Storage are virtual — they are just path prefixes in the file name. A file at path users/abc123/projects/report.pdf lives in a 'projects' folder, which is simply the string 'projects/' prefixed to the file name. The folder browser in the dashboard parses these prefixes client-side to simulate a directory tree.
Signed URLs grant time-limited read access to a specific file without exposing the Supabase service role key. An Edge Function uses the service role to call supabase.storage.from('files').createSignedUrl(path, expirySeconds). The signed URL is then stored in a shared_links table so you can list, revoke, and manage all active shares from the dashboard.
Storage quotas are tracked via a user_storage table that stores total_bytes_used. Every upload increments this counter via a Supabase Database Function. Upload attempts that would exceed the quota are rejected before the file is sent to storage.
Final result
A complete private file sharing app with folders, signed share links, drag-and-drop uploads, and storage quotas — built entirely in Lovable.
Tech stack
Prerequisites
- 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
Build steps
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.
1Set up a file sharing system in Supabase:231. Create a PRIVATE Storage bucket named 'user-files' with:4 - Public: false5 - File size limit: 50MB6 - Allowed MIME types: image/*, application/pdf, application/zip, text/*, video/mp4, audio/*782. Storage bucket RLS policies:9 - SELECT: (auth.uid()::text = (storage.foldername(name))[1])10 - INSERT: (auth.uid()::text = (storage.foldername(name))[1])11 - DELETE: (auth.uid()::text = (storage.foldername(name))[1])12133. Database tables:14 - 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_at15 - 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_at16 - user_storage: id (references auth.users), total_bytes_used (bigint default 0), quota_bytes (bigint default 1073741824) -- 1GB default17184. RLS on database tables:19 - files: users can SELECT/INSERT/UPDATE/DELETE their own rows (user_id = auth.uid())20 - shared_links: users can SELECT/UPDATE their own rows (user_id = auth.uid()). Service role for INSERT.21 - user_storage: users can SELECT their own row. Service role for INSERT/UPDATE.22235. SQL function update_storage_usage(p_user_id uuid, p_delta bigint) RETURNS void:24 INSERT INTO user_storage (id, total_bytes_used) VALUES (p_user_id, GREATEST(0, p_delta))25 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.
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.
1Build a FileUploader component at src/components/FileUploader.tsx.23Requirements:4- A drop zone div with dashed border that:5 - Changes border color and background on dragover6 - Accepts file drops (onDrop event) and click-to-open file input7 - Shows 'Drop files here or click to browse' text with an upload icon8- Props: currentFolder: string (the current folder path), onUploadComplete: () => void9- Multi-file support: handle FileList from both drag and input10- 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.'11- For each file:12 - Generate storage path: {user_id}/{currentFolder}/{filename} (replace spaces with underscores)13 - Call supabase.storage.from('user-files').upload(storagePath, file, { onUploadProgress: (p) => setProgress(p.loaded / p.total * 100) })14 - On success, insert a files row: { user_id, storage_path, filename, folder_path: currentFolder, size_bytes: file.size, mime_type: file.type }15 - Call update_storage_usage(user_id, file.size) RPC16 - Show a per-file Progress bar component17 - Show a green check on completion or red X on failure18- Call onUploadComplete() after all uploads finishPro 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.
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.
1Build the file browser at src/pages/FileBrowser.tsx.23State:4- currentFolder: string (starts as '/', updated by navigation)56Requirements:7- Breadcrumb navigation: split currentFolder by '/' and render each segment as a Button that navigates to that depth. Root shows a Home icon.8- 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.9- File list: fetch files WHERE user_id = auth.uid() AND folder_path = currentFolder ORDER BY created_at DESC10- 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 actions11- 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, Delete12- 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)13- Star filter: a 'Starred' toggle above the file list that filters to is_starred = true across all folders14- Storage usage indicator at the bottom: 'X.X GB of 1 GB used' with a thin Progress barExpected 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.
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.
1Build two things:231. Edge Function at supabase/functions/create-share-link/index.ts:4- Accept POST body: { file_id: string, expires_in_hours: number (1|24|168|720) }5- Verify the requesting user owns the file (SELECT from files WHERE id = file_id AND user_id = auth.uid() using the user's JWT)6- Calculate expiresInSeconds = expires_in_hours * 36007- Call supabase.storage.from('user-files').createSignedUrl(file.storage_path, expiresInSeconds) using service role client8- 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() }9- Return { share_url: data.signedUrl, expires_at }10112. Share Dialog component at src/components/ShareDialog.tsx:12- Props: fileId: string, filename: string13- A Select for expiry: '1 hour', '24 hours', '7 days', '30 days'14- A 'Generate Link' Button that calls the create-share-link Edge Function15- After generation, shows the signed URL in a read-only Input with a Copy button16- Shows expiry time as 'Link expires: [relative time]'17- A 'Manage all shares' link that navigates to the Shared Links pageExpected 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.
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.
1Build a Shared Links page at src/pages/SharedLinks.tsx.23Requirements:4- Fetch all rows from shared_links WHERE user_id = auth.uid() JOIN files ON file_id = files.id ORDER BY created_at DESC5- Show links in a Table with columns:6 - File icon + filename (from joined files row)7 - Shared link (truncated URL with Copy button)8 - Downloads (download_count integer)9 - Expires (relative time, red text if less than 1 hour remaining, 'Expired' badge if past expires_at)10 - Status (Active badge in green, Revoked in gray, Expired in red)11 - Revoke button (only for active, non-expired links) with AlertDialog confirmation12- Revoking sets is_revoked = true on the shared_links row13- Group links in two sections: 'Active' (not revoked, not expired) and 'Expired / Revoked'14- Show a count of active shares and a note that shares expire automaticallyExpected 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
1import { serve } from 'https://deno.land/std@0.168.0/http/server.ts'2import { createClient } from 'https://esm.sh/@supabase/supabase-js@2'34const corsHeaders = {5 'Access-Control-Allow-Origin': '*',6 'Access-Control-Allow-Headers': 'authorization, x-client-info, apikey, content-type',7 'Content-Type': 'application/json',8}910serve(async (req: Request) => {11 if (req.method === 'OPTIONS') {12 return new Response('ok', { headers: corsHeaders })13 }1415 const authHeader = req.headers.get('Authorization')16 if (!authHeader) {17 return new Response(JSON.stringify({ error: 'Unauthorized' }), { status: 401, headers: corsHeaders })18 }1920 // User-scoped client to verify ownership21 const userClient = createClient(22 Deno.env.get('SUPABASE_URL') ?? '',23 Deno.env.get('SUPABASE_ANON_KEY') ?? '',24 { global: { headers: { Authorization: authHeader } } }25 )2627 const { data: { user } } = await userClient.auth.getUser()28 if (!user) {29 return new Response(JSON.stringify({ error: 'Invalid token' }), { status: 401, headers: corsHeaders })30 }3132 const { file_id, expires_in_hours } = await req.json()33 const expiresInSeconds = Math.min((expires_in_hours ?? 24) * 3600, 30 * 24 * 3600)3435 // Verify ownership36 const { data: file, error: fileError } = await userClient37 .from('files')38 .select('id, storage_path, filename')39 .eq('id', file_id)40 .eq('user_id', user.id)41 .single()4243 if (fileError || !file) {44 return new Response(JSON.stringify({ error: 'File not found' }), { status: 404, headers: corsHeaders })45 }4647 // Service role client for storage operation48 const adminClient = createClient(49 Deno.env.get('SUPABASE_URL') ?? '',50 Deno.env.get('SUPABASE_SERVICE_ROLE_KEY') ?? ''51 )5253 const { data, error } = await adminClient.storage54 .from('user-files')55 .createSignedUrl(file.storage_path, expiresInSeconds)5657 if (error || !data) {58 return new Response(JSON.stringify({ error: 'Failed to generate link' }), { status: 500, headers: corsHeaders })59 }6061 const expiresAt = new Date(Date.now() + expiresInSeconds * 1000).toISOString()6263 await adminClient.from('shared_links').insert({64 file_id: file.id,65 user_id: user.id,66 signed_url: data.signedUrl,67 expires_at: expiresAt,68 })6970 return new Response(71 JSON.stringify({ share_url: data.signedUrl, expires_at: expiresAt }),72 { headers: corsHeaders }73 )74})Customization ideas
File versioning
Add a versions table that stores previous versions of a file when it is overwritten. Keep up to 10 versions per file. The file history view shows version number, size, upload date, and a 'Restore' button that swaps the current storage_path to the version path and updates the file row.
Password-protected share links
Add an optional password field to the Share dialog. Store the bcrypt hash of the password in shared_links. When someone opens the share link, show an Enter Password page before serving the signed URL. The password check happens in an Edge Function that verifies the hash before generating a short-lived (5 minute) download URL.
Preview for images and PDFs
Add a file preview panel that opens when a user clicks a filename. For images, show the image using a signed URL with 5-minute expiry. For PDFs, embed them in an iframe using the signed URL. For other file types, show file metadata and a download button. Supabase Storage supports image transformations via the transform parameter in the signed URL.
Team shared drive
Add a teams table and a shared_drives table. Create a separate Storage bucket per team with RLS policies allowing all team members to read/write. Team members see both their personal drive and the shared team drive in the sidebar. File ownership in the files table is either user_id or team_id.
Download tracking webhook
Add a download_count increment trigger: whenever a signed URL is accessed (proxied through an Edge Function rather than direct storage URL), log the download event in link_clicks and increment shared_links.download_count. This provides accurate download analytics per shared link.
Common pitfalls
Pitfall: Using a public Storage bucket for user files
How to avoid: 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.
Pitfall: Not scoping the storage path with user_id as the first folder segment
How to avoid: 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}`.
Pitfall: Updating storage quota with a read-then-write instead of an atomic SQL update
How to avoid: 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.
Pitfall: Storing signed URLs in the database long-term without tracking revocation
How to avoid: 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.
AI prompts to try
Copy these prompts to build this project faster.
I'm building a file sharing app with Supabase Storage. I have a private bucket called 'user-files' where each file is stored at user_id/folder/filename. I want to implement folder navigation without storing folder records separately. Explain how to derive the folder tree from the stored file paths client-side in TypeScript. Given an array of file paths like ['abc/documents/report.pdf', 'abc/images/photo.jpg', 'abc/documents/invoices/q1.pdf'], write a function that returns the folder tree structure for the current directory level.
Add a multi-file drag selection feature to the file browser. When the user holds Shift and clicks multiple files, they are selected (highlighted with a blue checkbox). Show a selection toolbar at the top of the file list when any files are selected: 'X files selected' text, a 'Download as ZIP' Button, a 'Move to folder' Button (opens a folder picker Dialog), and a 'Delete selected' Button with AlertDialog confirmation. Implement the download as ZIP by calling a Supabase Edge Function that fetches all selected files by their storage paths, creates a ZIP archive using the Deno standard library, and returns it as an octet-stream response.
In Supabase, create a scheduled Edge Function that runs daily to clean up orphaned storage files. The function should: list all files in the user-files bucket using the service role. For each file, check if a corresponding row exists in the files database table by storage_path. If no row exists, the file is orphaned — delete it from storage. Log a summary of how many orphaned files were deleted and their total size in bytes.
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.
Talk to an Expert
Our team has built 600+ apps. Get personalized help with your project.
Book a free consultation