# How to Build File sharing app with V0

- Tool: v0
- Difficulty: Intermediate
- Compatibility: V0 Premium or higher
- Last updated: April 2026

## TL;DR

Build a file sharing app with V0 using Next.js, Supabase Storage for uploads, and shareable links with expiry and password protection. You'll create a drag-and-drop uploader, link management with download limits, and access logging — all in about 1-2 hours without touching a terminal.

## Before you start

- A V0 account (Premium plan for upload component iterations)
- A Supabase project (free tier works — connect via V0's Connect panel)
- Understanding of file types you want to support (images, documents, archives)
- A use case: client deliverables, team file sharing, or public downloads

## Step-by-step guide

### 1. Set up the project and file storage schema

Open V0, create a new project, and connect Supabase. Create the schema for files, share links, and download logs. Configure a Supabase Storage bucket for file uploads.

```
// Paste this prompt into V0's AI chat:
// Build a file sharing app. Create a Supabase schema with:
// 1. files: id (uuid PK), owner_id (uuid FK to auth.users), filename (text), original_name (text), file_size (bigint), mime_type (text), storage_path (text), created_at (timestamptz)
// 2. share_links: id (uuid PK), file_id (uuid FK to files), token (text unique default gen_random_uuid()), password_hash (text), expires_at (timestamptz), max_downloads (int), download_count (int default 0), is_active (boolean default true), created_at (timestamptz)
// 3. download_logs: id (uuid PK), share_link_id (uuid FK to share_links), ip_address (text), user_agent (text), downloaded_at (timestamptz default now())
// Create a Supabase Storage bucket 'files' with 50MB file size limit.
// Add RLS: users see their own files, anyone with a valid share token can download.
```

> Pro tip: Configure the 'files' Storage bucket in Supabase Dashboard with RLS that allows authenticated uploads and restricts downloads to signed URL paths only.

**Expected result:** Database schema created with file tracking, share links, and download logs. Storage bucket configured for uploads.

### 2. Build the upload page with drag-and-drop

Create the main upload page with a drag-and-drop zone that supports multiple files. Each file shows an upload progress bar, and completed uploads appear in the file list below.

```
// Paste this prompt into V0's AI chat:
// Build a file upload page at app/page.tsx with a 'use client' upload component.
// Requirements:
// - A large drag-and-drop zone that accepts any file type up to 50MB
// - Support multiple file selection (both drag-drop and click-to-browse)
// - Show each file being uploaded with: filename, file size, Progress bar, cancel Button
// - Upload each file to Supabase Storage bucket 'files' with path: {user_id}/{uuid}-{filename}
// - After upload, insert a record into the files table with storage_path, file_size, mime_type
// - Below the upload zone, show "My Files" as a Table with columns: filename (with file type icon), size (formatted), uploaded date, actions (share Button, delete Button)
// - The share Button opens a Dialog for creating a share link with:
//   - DatePicker for expiry date (optional)
//   - Switch for password protection with Input for password
//   - Select for max downloads (unlimited, 1, 5, 10, 25)
//   - "Create Link" Button that generates the share URL
//   - Copy-to-clipboard Button for the generated link
// - Use Card to wrap the upload zone and Badge for file type indicators
```

**Expected result:** Users can drag and drop files, see upload progress, and manage uploaded files with share link creation dialogs.

### 3. Create the download API route with validation

Build the download route that validates the share link token, checks expiry, password, and download limits, then redirects to a short-lived Supabase Storage signed URL.

```
import { NextRequest, NextResponse } from 'next/server'
import { createClient } from '@supabase/supabase-js'
import { createHash } from 'crypto'

const supabase = createClient(
  process.env.SUPABASE_URL!,
  process.env.SUPABASE_SERVICE_ROLE_KEY!
)

export async function GET(
  req: NextRequest,
  { params }: { params: Promise<{ token: string }> }
) {
  const { token } = await params
  const password = req.nextUrl.searchParams.get('p')

  const { data: link } = await supabase
    .from('share_links')
    .select('*, files(storage_path, original_name)')
    .eq('token', token)
    .eq('is_active', true)
    .single()

  if (!link) {
    return NextResponse.json({ error: 'Link not found' }, { status: 404 })
  }

  if (link.expires_at && new Date(link.expires_at) < new Date()) {
    return NextResponse.json({ error: 'Link expired' }, { status: 410 })
  }

  if (link.max_downloads && link.download_count >= link.max_downloads) {
    return NextResponse.json({ error: 'Download limit reached' }, { status: 429 })
  }

  if (link.password_hash) {
    const hash = createHash('sha256').update(password ?? '').digest('hex')
    if (hash !== link.password_hash) {
      return NextResponse.json({ error: 'Invalid password' }, { status: 401 })
    }
  }

  const { data: signedUrl } = await supabase.storage
    .from('files')
    .createSignedUrl(link.files.storage_path, 30)

  await supabase
    .from('share_links')
    .update({ download_count: link.download_count + 1 })
    .eq('id', link.id)

  await supabase.from('download_logs').insert({
    share_link_id: link.id,
    ip_address: req.headers.get('x-forwarded-for') ?? 'unknown',
    user_agent: req.headers.get('user-agent') ?? 'unknown',
  })

  return NextResponse.redirect(signedUrl!.signedUrl)
}
```

> Pro tip: Use a 30-second TTL on signed URLs so they expire quickly after the redirect. This prevents URL sharing — each download request generates a fresh signed URL.

**Expected result:** The download route validates all constraints (expiry, password, limit), logs the download, and redirects to a short-lived signed URL.

### 4. Build the public download page

Create the public-facing download page where recipients see file info and enter a password if required. The page fetches share link metadata server-side and renders the download interface.

```
// Paste this prompt into V0's AI chat:
// Build a public download page at app/share/[token]/page.tsx.
// Requirements:
// - Server Component that fetches the share link with file info by token
// - Show a centered Card with: file icon based on mime_type, file name, file size formatted, expiry date if set
// - If the link is expired, show an error message: "This link has expired"
// - If download limit reached, show: "Download limit reached"
// - If password protected, show a 'use client' form with Input for password and "Download" Button
// - If no password, show a "Download" Button directly
// - The download Button links to /api/download/{token} (with password as query param if needed)
// - Show download count: "Downloaded X times" or "X of Y downloads used"
// - Add branding footer: "Shared via [Your App Name]"
// - Use Badge for file type, Card for the main container
// - Handle 404 if token doesn't exist with not-found.tsx
```

**Expected result:** Recipients see file info on the download page. Password-protected files show an input field. Expired or used links show appropriate error messages.

### 5. Add download history and file management

Build the file detail page showing all share links and their download history. Users can create additional share links, revoke existing ones, and view download analytics.

```
// Paste this prompt into V0's AI chat:
// Build a file detail page at app/files/[id]/page.tsx.
// Requirements:
// - Server Component that fetches the file with all share_links and their download_logs
// - File info Card: filename, size, mime_type, uploaded date, storage path
// - "Create Share Link" Button that opens a Dialog (same as upload page share dialog)
// - Active share links Table with columns: link URL (truncated with copy Button), expiry date, downloads used/max, password (yes/no Badge), created date, Revoke Button
// - Revoke sets is_active to false via Server Action
// - Download history Table below: download date, IP address (masked: 192.168.x.x), user agent (browser name), which share link was used
// - Stats Cards: total downloads across all links, active links count, last download date
// - Delete file Button with AlertDialog confirmation that removes from Storage and database
// - Use Tabs to switch between Share Links and Download History views
```

**Expected result:** File detail shows all share links with download analytics. Users can create, revoke, and monitor share links.

## Complete code example

File: `app/api/download/[token]/route.ts`

```typescript
import { NextRequest, NextResponse } from 'next/server'
import { createClient } from '@supabase/supabase-js'
import { createHash } from 'crypto'

const supabase = createClient(
  process.env.SUPABASE_URL!,
  process.env.SUPABASE_SERVICE_ROLE_KEY!
)

export async function GET(
  req: NextRequest,
  { params }: { params: Promise<{ token: string }> }
) {
  const { token } = await params
  const password = req.nextUrl.searchParams.get('p')

  const { data: link } = await supabase
    .from('share_links')
    .select('*, files(storage_path, original_name)')
    .eq('token', token)
    .eq('is_active', true)
    .single()

  if (!link) {
    return NextResponse.json({ error: 'Not found' }, { status: 404 })
  }

  if (link.expires_at && new Date(link.expires_at) < new Date()) {
    return NextResponse.json({ error: 'Expired' }, { status: 410 })
  }

  if (link.max_downloads && link.download_count >= link.max_downloads) {
    return NextResponse.json({ error: 'Limit reached' }, { status: 429 })
  }

  if (link.password_hash) {
    const hash = createHash('sha256').update(password ?? '').digest('hex')
    if (hash !== link.password_hash) {
      return NextResponse.json({ error: 'Wrong password' }, { status: 401 })
    }
  }

  const { data } = await supabase.storage
    .from('files')
    .createSignedUrl(link.files.storage_path, 30)

  await supabase
    .from('share_links')
    .update({ download_count: link.download_count + 1 })
    .eq('id', link.id)

  await supabase.from('download_logs').insert({
    share_link_id: link.id,
    ip_address: req.headers.get('x-forwarded-for') ?? 'unknown',
    user_agent: req.headers.get('user-agent') ?? 'unknown',
  })

  return NextResponse.redirect(data!.signedUrl)
}
```

## Common mistakes

- **Loading entire files into serverless function memory for downloads** — Serverless functions have limited memory (128MB-1GB). Large files cause out-of-memory crashes. Fix: Use Supabase Storage signed URLs with short TTL and redirect the user. The file streams directly from Supabase CDN, bypassing your function.
- **Not hashing passwords for share links** — Storing passwords in plain text means a database breach exposes all share link passwords. Fix: Hash passwords with SHA-256 before storing in password_hash. Compare hashes on download requests.
- **Using long-lived signed URLs for downloads** — Long-lived URLs can be shared, bypassing your download limits and password protection. Fix: Use 30-second TTL on signed URLs generated fresh for each download request.

## Best practices

- Use Supabase Storage signed URLs with short TTLs (30 seconds) to prevent URL sharing and enforce download limits.
- Hash share link passwords with SHA-256 before storing — never store plain text passwords.
- Redirect to signed URLs instead of streaming files through serverless functions to avoid memory limits.
- Log all downloads with IP and user agent for analytics and abuse detection.
- Configure Supabase Storage bucket with RLS that allows authenticated uploads and restricts reads to signed URLs.
- Use Design Mode (Option+D) to adjust the upload zone and file card layouts without spending V0 credits.

## Frequently asked questions

### How do I handle large file uploads?

Supabase Storage supports files up to 50MB on the free tier and 5GB on paid plans. Files upload directly from the browser to Supabase Storage, bypassing your serverless function entirely.

### Can I set download limits on shared links?

Yes. The share_links table has max_downloads and download_count columns. The download API route checks if download_count >= max_downloads before serving the file.

### How do password-protected links work?

Passwords are hashed with SHA-256 and stored in the share_links table. The public download page shows a password Input. The hash is compared in the download API route before generating a signed URL.

### How do I deploy this app?

Publish via V0's Share menu. Set SUPABASE_URL and SUPABASE_SERVICE_ROLE_KEY in the Vars tab. Configure the Storage bucket in Supabase Dashboard with appropriate RLS policies.

### Can files be shared without creating an account?

The uploader needs an account. Recipients can download without an account by visiting the share link URL. Password protection adds an extra layer of security.

### Can RapidDev help build a custom file sharing platform?

Yes. RapidDev has built 600+ apps including enterprise file sharing systems with team workspaces, audit trails, and compliance features. Book a free consultation to discuss your needs.

---

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