Overview
Production-grade file upload system with drag-and-drop interface, real-time progress tracking, automatic retry on failure, and cloud storage integration. Handles validation, size limits, file type restrictions, and multi-file uploads with a beautiful UX.
Use Cases
- Profile pictures: Avatar uploads with crop and preview
- Document storage: Contracts, invoices, receipts in SaaS apps
- Content creation: Blog images, product photos for e-commerce
- Media platforms: Social networks, portfolio sites, galleries
When to Use This Pattern
Use this pattern when you need to:
- Allow users to upload files to your application
- Store files in cloud storage (not local disk)
- Show upload progress and handle errors gracefully
- Support drag-and-drop and click-to-upload
- Validate file types and sizes before upload
- Handle large files (>10MB) efficiently
Pro Tips
Before you start implementing, read these carefully:
- Never store files in your database - Store URLs in database, files in cloud storage (S3, Cloudinary, Vercel Blob)
- Client-side validation is UX, server-side is security - Always validate file type and size on backend
- Use presigned URLs for large files - Upload directly to S3/storage, bypass your server
- Show progress bars - Users abandon uploads without feedback
- Implement chunked uploads for files >100MB - Allows resume on network failures
Implementation Phases
Phase 1: Storage Setup
Choose your cloud storage provider:
Option A: Vercel Blob (Easiest for Next.js)
npm install @vercel/blob
Option B: Supabase Storage (Best if using Supabase for database)
npm install @supabase/supabase-js
Option C: Cloudinary (Best for image/video transformations)
npm install cloudinary
Option D: AWS S3 (Most flexible, requires more setup)
npm install @aws-sdk/client-s3 @aws-sdk/s3-request-presigner
Environment variables:
# Vercel Blob
BLOB_READ_WRITE_TOKEN=vercel_blob_xxx
# Supabase
NEXT_PUBLIC_SUPABASE_URL=https://xxx.supabase.co
NEXT_PUBLIC_SUPABASE_ANON_KEY=xxx
SUPABASE_SERVICE_ROLE_KEY=xxx
# Cloudinary
CLOUDINARY_CLOUD_NAME=xxx
CLOUDINARY_API_KEY=xxx
CLOUDINARY_API_SECRET=xxx
# AWS S3
AWS_ACCESS_KEY_ID=xxx
AWS_SECRET_ACCESS_KEY=xxx
AWS_REGION=us-east-1
AWS_S3_BUCKET_NAME=my-app-uploads
Phase 2: Database Schema
Track uploaded files in database:
model Upload {
id String @id @default(uuid())
userId String
filename String
originalName String // User's original filename
fileSize Int // Size in bytes
mimeType String // image/jpeg, application/pdf, etc.
url String // Cloud storage URL
storageKey String // Storage provider's key/path
metadata Json? // Width, height, duration, etc.
uploadedAt DateTime @default(now())
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@index([userId, uploadedAt])
@@index([mimeType])
}
Why track uploads:
- User file management (list, delete their files)
- Storage quota enforcement
- Audit trail and analytics
- Orphan file cleanup (files uploaded but not used)
Phase 3: Server-Side Upload API
Upload endpoint with validation (app/api/upload/route.ts):
import { put } from '@vercel/blob'
import { NextResponse } from 'next/server'
import { getServerSession } from 'next-auth'
const MAX_FILE_SIZE = 10 * 1024 * 1024 // 10MB
const ALLOWED_TYPES = ['image/jpeg', 'image/png', 'image/webp', 'application/pdf']
export async function POST(request: Request) {
const session = await getServerSession()
if (!session?.user?.email) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
try {
const formData = await request.formData()
const file = formData.get('file') as File
if (!file) {
return NextResponse.json({ error: 'No file provided' }, { status: 400 })
}
// Validate file size
if (file.size > MAX_FILE_SIZE) {
return NextResponse.json(
{ error: `File too large. Max size: ${MAX_FILE_SIZE / 1024 / 1024}MB` },
{ status: 400 }
)
}
// Validate file type
if (!ALLOWED_TYPES.includes(file.type)) {
return NextResponse.json(
{ error: `Invalid file type. Allowed: ${ALLOWED_TYPES.join(', ')}` },
{ status: 400 }
)
}
// Upload to Vercel Blob
const blob = await put(file.name, file, {
access: 'public',
addRandomSuffix: true, // Prevent filename collisions
})
// Save to database
const user = await prisma.user.findUnique({
where: { email: session.user.email },
})
const upload = await prisma.upload.create({
data: {
userId: user!.id,
filename: blob.pathname,
originalName: file.name,
fileSize: file.size,
mimeType: file.type,
url: blob.url,
storageKey: blob.pathname,
},
})
return NextResponse.json({
id: upload.id,
url: blob.url,
filename: file.name,
})
} catch (error) {
console.error('Upload failed:', error)
return NextResponse.json(
{ error: 'Upload failed' },
{ status: 500 }
)
}
}
Phase 4: Client-Side Upload Component
React component with drag-and-drop:
'use client'
import { useState, useCallback } from 'react'
import { useDropzone } from 'react-dropzone'
export default function FileUpload({ onUploadComplete }: { onUploadComplete?: (url: string) => void }) {
const [uploading, setUploading] = useState(false)
const [progress, setProgress] = useState(0)
const [error, setError] = useState<string | null>(null)
const [uploadedUrl, setUploadedUrl] = useState<string | null>(null)
const onDrop = useCallback(async (acceptedFiles: File[]) => {
const file = acceptedFiles[0]
if (!file) return
setUploading(true)
setError(null)
setProgress(0)
try {
const formData = new FormData()
formData.append('file', file)
// Simulate progress (real progress requires XMLHttpRequest)
const progressInterval = setInterval(() => {
setProgress(prev => Math.min(prev + 10, 90))
}, 200)
const res = await fetch('/api/upload', {
method: 'POST',
body: formData,
})
clearInterval(progressInterval)
setProgress(100)
if (!res.ok) {
const data = await res.json()
throw new Error(data.error || 'Upload failed')
}
const data = await res.json()
setUploadedUrl(data.url)
onUploadComplete?.(data.url)
} catch (err) {
setError(err instanceof Error ? err.message : 'Upload failed')
} finally {
setUploading(false)
}
}, [onUploadComplete])
const { getRootProps, getInputProps, isDragActive } = useDropzone({
onDrop,
accept: {
'image/*': ['.jpeg', '.jpg', '.png', '.webp'],
'application/pdf': ['.pdf'],
},
maxSize: 10 * 1024 * 1024, // 10MB
maxFiles: 1,
})
return (
<div className="w-full max-w-md mx-auto">
{!uploadedUrl ? (
<div
{...getRootProps()}
className={`
border-2 border-dashed rounded-xl p-8 text-center cursor-pointer transition-colors
${isDragActive ? 'border-blue-500 bg-blue-50' : 'border-stone-300 hover:border-stone-400'}
${uploading ? 'pointer-events-none opacity-50' : ''}
`}
>
<input {...getInputProps()} />
{uploading ? (
<div>
<div className="text-lg font-semibold text-stone-900 mb-3">
Uploading...
</div>
<div className="w-full bg-stone-200 rounded-full h-3 mb-2">
<div
className="bg-blue-600 h-3 rounded-full transition-all duration-300"
style={{ width: `${progress}%` }}
/>
</div>
<div className="text-sm text-stone-600">{progress}%</div>
</div>
) : (
<div>
<div className="text-4xl mb-3">📁</div>
<div className="text-lg font-semibold text-stone-900 mb-2">
{isDragActive ? 'Drop file here' : 'Drag & drop file here'}
</div>
<div className="text-sm text-stone-600 mb-3">
or click to browse
</div>
<div className="text-xs text-stone-500">
Max 10MB • Images & PDFs only
</div>
</div>
)}
</div>
) : (
<div className="border-2 border-green-300 bg-green-50 rounded-xl p-6 text-center">
<div className="text-4xl mb-3">✓</div>
<div className="text-lg font-semibold text-green-900 mb-2">
Upload successful!
</div>
<a
href={uploadedUrl}
target="_blank"
rel="noopener noreferrer"
className="text-sm text-blue-600 hover:underline"
>
View file →
</a>
<button
onClick={() => setUploadedUrl(null)}
className="mt-4 text-sm text-stone-600 hover:text-stone-900"
>
Upload another
</button>
</div>
)}
{error && (
<div className="mt-4 p-3 bg-red-50 border border-red-200 rounded-lg text-red-900 text-sm">
{error}
</div>
)}
</div>
)
}
Install required package:
npm install react-dropzone
Phase 5: Advanced Features
Multi-file upload:
const onDrop = useCallback(async (acceptedFiles: File[]) => {
setUploading(true)
const uploadPromises = acceptedFiles.map(async (file) => {
const formData = new FormData()
formData.append('file', file)
const res = await fetch('/api/upload', {
method: 'POST',
body: formData,
})
return res.json()
})
try {
const results = await Promise.all(uploadPromises)
setUploadedUrls(results.map(r => r.url))
} catch (err) {
setError('Some uploads failed')
} finally {
setUploading(false)
}
}, [])
Retry failed uploads:
async function uploadWithRetry(file: File, maxRetries = 3) {
for (let attempt = 1; attempt <= maxRetries; attempt++) {
try {
const formData = new FormData()
formData.append('file', file)
const res = await fetch('/api/upload', {
method: 'POST',
body: formData,
})
if (res.ok) {
return await res.json()
}
if (attempt === maxRetries) {
throw new Error('Upload failed after retries')
}
// Exponential backoff
await new Promise(resolve => setTimeout(resolve, 1000 * Math.pow(2, attempt)))
} catch (err) {
if (attempt === maxRetries) throw err
}
}
}
Edge Cases to Handle
Critical Edge Cases
File extension vs MIME type mismatch:
- User renames
image.exetoimage.jpg - Client sends MIME type but may be spoofed
- Solution: Use
file-typepackage to check actual file signature
npm install file-type
Concurrent uploads of same file:
- User clicks upload button multiple times
- Creates duplicate files in storage
- Solution: Disable upload button while uploading, use upload ID to deduplicate
Orphaned files (uploaded but never used):
- User uploads file but abandons form
- File exists in storage but not referenced anywhere
- Solution: Cron job to delete files uploaded >24 hours ago with no references
Storage quota exceeded:
- User has uploaded 1GB limit
- Need to enforce per-user quotas
- Solution: Calculate total file size per user, reject if over limit
const totalSize = await prisma.upload.aggregate({
where: { userId: user.id },
_sum: { fileSize: true },
})
const QUOTA_BYTES = 1024 * 1024 * 1024 // 1GB
if (totalSize._sum.fileSize + file.size > QUOTA_BYTES) {
return NextResponse.json(
{ error: 'Storage quota exceeded' },
{ status: 413 }
)
}
Malicious file uploads:
- User uploads PHP file disguised as image
- Executable files in disguise
- Solution: Strict MIME type checking, scan with antivirus API (optional)
Network interruptions mid-upload:
- User's connection drops during upload
- File partially uploaded, corrupted
- Solution: Implement chunked uploads with resumability
Large file uploads timing out:
- Server request timeout (usually 30-60 seconds)
- Solution: For files >50MB, use presigned URLs to upload directly to storage
Tech Stack Recommendations
Minimum Viable Stack
- Framework: Next.js with API routes
- Storage: Vercel Blob (easiest, free tier)
- Upload UI: react-dropzone
- Database: Track URLs in existing database
Production-Grade Stack
- Framework: Next.js 15 with App Router
- Storage: Vercel Blob or Supabase Storage
- Upload UI: react-dropzone + custom progress tracking
- Database: Prisma + PostgreSQL (track uploads, quotas)
- Image processing: Sharp for thumbnails (see image-optimization spec)
- Antivirus: ClamAV API or VirusTotal (for sensitive apps)
Full Implementation Prompt
Copy this prompt to use with Claude Code:
I need to implement a file upload system with drag-and-drop, progress tracking, and cloud storage. Before we start, help me review my requirements.
First, let me understand my use case:
- What types of files will users upload? (Images, PDFs, videos, any file type?)
- What's the max file size I should allow? (Recommend: 10MB for images, 100MB for videos)
- Do I already have a cloud storage account? (Vercel Blob, S3, Cloudinary, Supabase?)
- Should uploads be public or private? (Profile pictures = public, documents = private)
- Do I need per-user storage quotas? (1GB per user, unlimited for paid plans, etc.)
Then let's discuss these upload flow decisions:
- Single file or multi-file uploads?
- Should uploads be tied to user accounts or allow anonymous uploads?
- What happens to files when user deletes their account?
- Do I need to scan for malware? (recommended for file-sharing apps)
- Should I generate thumbnails for images? (see image-optimization spec)
Then we'll implement in phases: Phase 1: Set up cloud storage provider (Vercel Blob, S3, etc.) Phase 2: Database schema for tracking uploads Phase 3: Server-side upload API with validation Phase 4: Client-side upload component with drag-and-drop Phase 5: Progress tracking and error handling Phase 6: File management UI (list user's uploads, delete)
After implementation, let's test:
- Upload valid file (should succeed)
- Upload oversized file (should reject with clear error)
- Upload invalid file type (should reject)
- Drag-and-drop file (should work)
- Upload while offline (should show error and allow retry)
- Upload same file twice (should handle gracefully)
Sound good? Let's start by setting up your cloud storage provider.
Related Feature Specs
- Image Optimization - Thumbnails, compression, CDN delivery
- Video Upload - Large files, transcoding, streaming
- Document Management - PDF preview, versioning, search
Success Criteria
You've successfully implemented this when:
✅ Users can drag-and-drop files or click to browse ✅ Upload progress bar shows real-time progress ✅ File validation happens on client AND server ✅ Files are stored in cloud storage (not local disk) ✅ File URLs are saved in database linked to user ✅ Errors show clear, actionable messages ✅ Large files upload without timing out ✅ Users can view and delete their uploaded files
Common Mistakes to Avoid
❌ Storing files in database (use URLs only) ❌ No server-side validation (security risk) ❌ No progress feedback (users abandon uploads) ❌ Not handling upload failures (network issues common) ❌ Allowing unlimited file sizes (abuse risk) ❌ Not cleaning up orphaned files (storage costs) ❌ Blocking uploads (use async, don't freeze UI) ❌ Not setting CORS if uploading directly to storage
Implementation Checklist
Storage Setup:
- Choose cloud storage provider
- Create account and get API keys
- Store credentials in environment variables
- Test upload from server-side
Database:
- Create Upload model with required fields
- Add indexes on userId, uploadedAt
- Link to User model with foreign key
Backend:
- Create /api/upload endpoint
- Validate file size (max 10MB or your limit)
- Validate file type (MIME type check)
- Upload to cloud storage
- Save URL to database
- Return upload URL to client
Frontend:
- Install react-dropzone
- Create FileUpload component
- Add drag-and-drop zone
- Show upload progress bar
- Handle upload errors
- Show success state with preview/link
Edge Cases:
- Disable upload button while uploading
- Handle network failures with retry
- Validate file extension matches content
- Enforce per-user storage quotas
- Clean up orphaned files (uploaded but unused)
Testing:
- Test drag-and-drop
- Test click to browse
- Test file too large error
- Test invalid file type error
- Test network interruption
- Test concurrent uploads
- Test upload quota exceeded
Last Updated: 2025-12-04 Difficulty: Intermediate Estimated Time: 4-6 hours Prerequisites: Next.js API routes, cloud storage basics, form handling
Built with this spec? Share your implementation and we'll feature it in our showcase!