All Features / File Upload System
4-6 hours
intermediate

File Upload System

Drag-drop, progress tracking, retry logic, cloud storage

filesstoragesaas

Paste this into Claude Code to start implementing

🔧Works With

This spec is compatible with:

The implementation prompt includes guidance for these tech stacks.

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:

  1. Never store files in your database - Store URLs in database, files in cloud storage (S3, Cloudinary, Vercel Blob)
  2. Client-side validation is UX, server-side is security - Always validate file type and size on backend
  3. Use presigned URLs for large files - Upload directly to S3/storage, bypass your server
  4. Show progress bars - Users abandon uploads without feedback
  5. 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.exe to image.jpg
  • Client sends MIME type but may be spoofed
  • Solution: Use file-type package 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:

  1. What types of files will users upload? (Images, PDFs, videos, any file type?)
  2. What's the max file size I should allow? (Recommend: 10MB for images, 100MB for videos)
  3. Do I already have a cloud storage account? (Vercel Blob, S3, Cloudinary, Supabase?)
  4. Should uploads be public or private? (Profile pictures = public, documents = private)
  5. 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

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!

Need Implementation Help?

Get expert guidance on architecture, security, and best practices.

Book a Consultation
File Upload System | Claude Code Implementation Guide | HashBuilds