File Upload Patterns

Build drag-and-drop file upload components with preview and progress tracking.

Overview#

File uploads are common in modern applications. This pattern covers:

  • Drag-and-drop upload zone
  • File preview with thumbnails
  • Upload progress indicators
  • Multi-file uploads
  • Server-side handling

Prerequisites#

npm install react-dropzone lucide-react

Basic File Upload#

A simple drag-and-drop upload zone.

1// components/forms/FileUploadForm.tsx 2'use client' 3 4import { useState, useCallback } from 'react' 5import { useDropzone } from 'react-dropzone' 6import { Upload, X, File } from 'lucide-react' 7import { cn } from '@/lib/utils' 8 9export function FileUploadForm() { 10 const [files, setFiles] = useState<File[]>([]) 11 const [uploading, setUploading] = useState(false) 12 13 const onDrop = useCallback((acceptedFiles: File[]) => { 14 setFiles(prev => [...prev, ...acceptedFiles]) 15 }, []) 16 17 const { getRootProps, getInputProps, isDragActive } = useDropzone({ 18 onDrop, 19 accept: { 'image/*': ['.png', '.jpg', '.jpeg', '.webp'] }, 20 maxSize: 5 * 1024 * 1024 // 5MB 21 }) 22 23 const removeFile = (index: number) => { 24 setFiles(f => f.filter((_, i) => i !== index)) 25 } 26 27 async function handleUpload() { 28 setUploading(true) 29 30 const formData = new FormData() 31 files.forEach(file => formData.append('files', file)) 32 33 try { 34 await fetch('/api/upload', { method: 'POST', body: formData }) 35 setFiles([]) 36 } catch (error) { 37 console.error('Upload failed:', error) 38 } finally { 39 setUploading(false) 40 } 41 } 42 43 return ( 44 <div className="space-y-4"> 45 <div 46 {...getRootProps()} 47 className={cn( 48 'cursor-pointer rounded-lg border-2 border-dashed p-8 text-center transition-colors', 49 isDragActive 50 ? 'border-blue-500 bg-blue-50' 51 : 'border-gray-300 hover:border-gray-400' 52 )} 53 > 54 <input {...getInputProps()} /> 55 <Upload className="mx-auto h-8 w-8 text-gray-400" /> 56 <p className="mt-2 text-sm text-gray-600"> 57 {isDragActive 58 ? 'Drop files here...' 59 : 'Drag files here, or click to select'} 60 </p> 61 <p className="mt-1 text-xs text-gray-500"> 62 PNG, JPG, WEBP up to 5MB 63 </p> 64 </div> 65 66 {files.length > 0 && ( 67 <div className="space-y-2"> 68 {files.map((file, i) => ( 69 <div key={i} className="flex items-center justify-between rounded border p-2"> 70 <div className="flex items-center gap-2"> 71 <File className="h-4 w-4 text-gray-400" /> 72 <span className="text-sm">{file.name}</span> 73 <span className="text-xs text-gray-500"> 74 ({(file.size / 1024).toFixed(1)} KB) 75 </span> 76 </div> 77 <button 78 onClick={() => removeFile(i)} 79 className="rounded p-1 hover:bg-gray-100" 80 > 81 <X className="h-4 w-4" /> 82 </button> 83 </div> 84 ))} 85 </div> 86 )} 87 88 <button 89 onClick={handleUpload} 90 disabled={!files.length || uploading} 91 className="rounded bg-black px-4 py-2 text-white disabled:opacity-50" 92 > 93 {uploading ? 'Uploading...' : `Upload ${files.length} file(s)`} 94 </button> 95 </div> 96 ) 97}

Upload with Preview#

Show image thumbnails before uploading.

1// components/forms/ImageUploadWithPreview.tsx 2'use client' 3 4import { useState, useCallback } from 'react' 5import { useDropzone } from 'react-dropzone' 6import { X, ImagePlus } from 'lucide-react' 7import Image from 'next/image' 8 9interface FileWithPreview extends File { 10 preview: string 11} 12 13export function ImageUploadWithPreview() { 14 const [files, setFiles] = useState<FileWithPreview[]>([]) 15 16 const onDrop = useCallback((acceptedFiles: File[]) => { 17 const filesWithPreview = acceptedFiles.map(file => 18 Object.assign(file, { 19 preview: URL.createObjectURL(file) 20 }) 21 ) 22 setFiles(prev => [...prev, ...filesWithPreview]) 23 }, []) 24 25 const { getRootProps, getInputProps, isDragActive } = useDropzone({ 26 onDrop, 27 accept: { 'image/*': [] }, 28 maxFiles: 4 29 }) 30 31 const removeFile = (index: number) => { 32 URL.revokeObjectURL(files[index].preview) 33 setFiles(f => f.filter((_, i) => i !== index)) 34 } 35 36 return ( 37 <div className="space-y-4"> 38 <div 39 {...getRootProps()} 40 className={`cursor-pointer rounded-lg border-2 border-dashed p-8 text-center ${ 41 isDragActive ? 'border-blue-500 bg-blue-50' : 'border-gray-300' 42 }`} 43 > 44 <input {...getInputProps()} /> 45 <ImagePlus className="mx-auto h-8 w-8 text-gray-400" /> 46 <p className="mt-2 text-sm text-gray-600"> 47 Drop images here or click to select 48 </p> 49 </div> 50 51 {files.length > 0 && ( 52 <div className="grid grid-cols-4 gap-4"> 53 {files.map((file, i) => ( 54 <div key={i} className="relative aspect-square overflow-hidden rounded-lg"> 55 <Image 56 src={file.preview} 57 alt={file.name} 58 fill 59 className="object-cover" 60 /> 61 <button 62 onClick={() => removeFile(i)} 63 className="absolute right-1 top-1 rounded-full bg-black/50 p-1 text-white hover:bg-black/70" 64 > 65 <X className="h-4 w-4" /> 66 </button> 67 </div> 68 ))} 69 </div> 70 )} 71 </div> 72 ) 73}

Upload with Progress#

Track upload progress for each file.

1// components/forms/UploadWithProgress.tsx 2'use client' 3 4import { useState, useCallback } from 'react' 5import { useDropzone } from 'react-dropzone' 6import { Upload, CheckCircle, XCircle, Loader2 } from 'lucide-react' 7 8interface UploadFile { 9 file: File 10 progress: number 11 status: 'pending' | 'uploading' | 'complete' | 'error' 12} 13 14export function UploadWithProgress() { 15 const [uploads, setUploads] = useState<UploadFile[]>([]) 16 17 const updateUpload = (index: number, updates: Partial<UploadFile>) => { 18 setUploads(prev => 19 prev.map((u, i) => (i === index ? { ...u, ...updates } : u)) 20 ) 21 } 22 23 const uploadFile = async (file: File, index: number) => { 24 updateUpload(index, { status: 'uploading', progress: 0 }) 25 26 try { 27 const formData = new FormData() 28 formData.append('file', file) 29 30 const xhr = new XMLHttpRequest() 31 32 xhr.upload.addEventListener('progress', (e) => { 33 if (e.lengthComputable) { 34 const progress = Math.round((e.loaded / e.total) * 100) 35 updateUpload(index, { progress }) 36 } 37 }) 38 39 await new Promise<void>((resolve, reject) => { 40 xhr.onload = () => { 41 if (xhr.status >= 200 && xhr.status < 300) { 42 resolve() 43 } else { 44 reject(new Error('Upload failed')) 45 } 46 } 47 xhr.onerror = () => reject(new Error('Upload failed')) 48 49 xhr.open('POST', '/api/upload') 50 xhr.send(formData) 51 }) 52 53 updateUpload(index, { status: 'complete', progress: 100 }) 54 } catch (error) { 55 updateUpload(index, { status: 'error' }) 56 } 57 } 58 59 const onDrop = useCallback((acceptedFiles: File[]) => { 60 const newUploads = acceptedFiles.map(file => ({ 61 file, 62 progress: 0, 63 status: 'pending' as const 64 })) 65 66 setUploads(prev => [...prev, ...newUploads]) 67 68 const startIndex = uploads.length 69 newUploads.forEach((_, i) => { 70 uploadFile(acceptedFiles[i], startIndex + i) 71 }) 72 }, [uploads.length]) 73 74 const { getRootProps, getInputProps, isDragActive } = useDropzone({ onDrop }) 75 76 return ( 77 <div className="space-y-4"> 78 <div 79 {...getRootProps()} 80 className={`cursor-pointer rounded-lg border-2 border-dashed p-8 text-center ${ 81 isDragActive ? 'border-blue-500 bg-blue-50' : 'border-gray-300' 82 }`} 83 > 84 <input {...getInputProps()} /> 85 <Upload className="mx-auto h-8 w-8 text-gray-400" /> 86 <p className="mt-2 text-sm text-gray-600">Drop files to upload</p> 87 </div> 88 89 {uploads.length > 0 && ( 90 <div className="space-y-2"> 91 {uploads.map((upload, i) => ( 92 <div key={i} className="rounded border p-3"> 93 <div className="flex items-center justify-between"> 94 <span className="text-sm font-medium">{upload.file.name}</span> 95 {upload.status === 'uploading' && ( 96 <Loader2 className="h-4 w-4 animate-spin text-blue-500" /> 97 )} 98 {upload.status === 'complete' && ( 99 <CheckCircle className="h-4 w-4 text-green-500" /> 100 )} 101 {upload.status === 'error' && ( 102 <XCircle className="h-4 w-4 text-red-500" /> 103 )} 104 </div> 105 {upload.status === 'uploading' && ( 106 <div className="mt-2 h-1 overflow-hidden rounded-full bg-gray-200"> 107 <div 108 className="h-full bg-blue-500 transition-all" 109 style={{ width: `${upload.progress}%` }} 110 /> 111 </div> 112 )} 113 </div> 114 ))} 115 </div> 116 )} 117 </div> 118 ) 119}

Server-Side Upload Handler#

Handle file uploads on the server.

1// app/api/upload/route.ts 2import { writeFile, mkdir } from 'fs/promises' 3import { join } from 'path' 4import { NextRequest, NextResponse } from 'next/server' 5import { auth } from '@/auth' 6 7const UPLOAD_DIR = join(process.cwd(), 'uploads') 8 9export async function POST(request: NextRequest) { 10 const session = await auth() 11 if (!session) { 12 return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) 13 } 14 15 const formData = await request.formData() 16 const files = formData.getAll('files') as File[] 17 18 if (files.length === 0) { 19 return NextResponse.json({ error: 'No files provided' }, { status: 400 }) 20 } 21 22 const uploadedFiles = [] 23 24 for (const file of files) { 25 // Validate file type 26 const allowedTypes = ['image/jpeg', 'image/png', 'image/webp'] 27 if (!allowedTypes.includes(file.type)) { 28 continue 29 } 30 31 // Validate file size (5MB) 32 if (file.size > 5 * 1024 * 1024) { 33 continue 34 } 35 36 // Generate unique filename 37 const ext = file.name.split('.').pop() 38 const filename = `${Date.now()}-${Math.random().toString(36).slice(2)}.${ext}` 39 40 // Ensure upload directory exists 41 await mkdir(UPLOAD_DIR, { recursive: true }) 42 43 // Write file 44 const bytes = await file.arrayBuffer() 45 const buffer = Buffer.from(bytes) 46 await writeFile(join(UPLOAD_DIR, filename), buffer) 47 48 uploadedFiles.push({ 49 filename, 50 url: `/uploads/${filename}`, 51 size: file.size 52 }) 53 } 54 55 return NextResponse.json({ files: uploadedFiles }) 56}

Best Practices#

  1. Validate file types - Check MIME types on both client and server
  2. Limit file sizes - Set reasonable limits and show clear error messages
  3. Show progress - Users need feedback for large file uploads
  4. Handle errors gracefully - Allow retrying failed uploads
  5. Clean up previews - Revoke object URLs when files are removed
  • Forms - Form handling patterns
  • Stripe - Uploading receipts and documents
  • Modals - Upload modals