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-reactBasic 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#
- Validate file types - Check MIME types on both client and server
- Limit file sizes - Set reasonable limits and show clear error messages
- Show progress - Users need feedback for large file uploads
- Handle errors gracefully - Allow retrying failed uploads
- Clean up previews - Revoke object URLs when files are removed