Tutorial: File Uploads

Implement secure file uploads with image processing, storage, and delivery.

What You'll Build#

  • Presigned URL uploads to S3/R2
  • Image processing and optimization
  • File type validation
  • Progress tracking
  • CDN delivery

Prerequisites#

  • Next.js project
  • AWS S3 or Cloudflare R2 account
  • Bootspring initialized

Time Required#

Approximately 30 minutes.

Step 1: Set Up Storage#

Option A: AWS S3#

# .env.local AWS_ACCESS_KEY_ID=... AWS_SECRET_ACCESS_KEY=... AWS_REGION=us-east-1 AWS_S3_BUCKET=my-app-uploads

Option B: Cloudflare R2#

1# .env.local 2CLOUDFLARE_ACCOUNT_ID=... 3CLOUDFLARE_R2_ACCESS_KEY_ID=... 4CLOUDFLARE_R2_SECRET_ACCESS_KEY=... 5CLOUDFLARE_R2_BUCKET=my-app-uploads 6CLOUDFLARE_R2_PUBLIC_URL=https://uploads.yourdomain.com

Step 2: Apply Storage Skill#

bootspring skill apply storage/presigned-urls

Step 3: Install Dependencies#

npm install @aws-sdk/client-s3 @aws-sdk/s3-request-presigner sharp

Step 4: Create Storage Client#

1// lib/storage.ts 2import { 3 S3Client, 4 PutObjectCommand, 5 GetObjectCommand, 6 DeleteObjectCommand, 7} from '@aws-sdk/client-s3'; 8import { getSignedUrl } from '@aws-sdk/s3-request-presigner'; 9 10const isR2 = !!process.env.CLOUDFLARE_ACCOUNT_ID; 11 12const client = new S3Client( 13 isR2 14 ? { 15 region: 'auto', 16 endpoint: `https://${process.env.CLOUDFLARE_ACCOUNT_ID}.r2.cloudflarestorage.com`, 17 credentials: { 18 accessKeyId: process.env.CLOUDFLARE_R2_ACCESS_KEY_ID!, 19 secretAccessKey: process.env.CLOUDFLARE_R2_SECRET_ACCESS_KEY!, 20 }, 21 } 22 : { 23 region: process.env.AWS_REGION!, 24 credentials: { 25 accessKeyId: process.env.AWS_ACCESS_KEY_ID!, 26 secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY!, 27 }, 28 } 29); 30 31const bucket = isR2 32 ? process.env.CLOUDFLARE_R2_BUCKET! 33 : process.env.AWS_S3_BUCKET!; 34 35export async function getUploadUrl( 36 key: string, 37 contentType: string, 38 expiresIn = 3600 39) { 40 const command = new PutObjectCommand({ 41 Bucket: bucket, 42 Key: key, 43 ContentType: contentType, 44 }); 45 46 const url = await getSignedUrl(client, command, { expiresIn }); 47 48 return { 49 uploadUrl: url, 50 key, 51 publicUrl: getPublicUrl(key), 52 }; 53} 54 55export async function deleteFile(key: string) { 56 const command = new DeleteObjectCommand({ 57 Bucket: bucket, 58 Key: key, 59 }); 60 61 await client.send(command); 62} 63 64export function getPublicUrl(key: string): string { 65 if (isR2) { 66 return `${process.env.CLOUDFLARE_R2_PUBLIC_URL}/${key}`; 67 } 68 return `https://${bucket}.s3.${process.env.AWS_REGION}.amazonaws.com/${key}`; 69} 70 71// File validation 72export const ALLOWED_IMAGE_TYPES = [ 73 'image/jpeg', 74 'image/png', 75 'image/gif', 76 'image/webp', 77]; 78 79export const ALLOWED_DOCUMENT_TYPES = [ 80 'application/pdf', 81 'application/msword', 82 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', 83]; 84 85export const MAX_FILE_SIZE = 10 * 1024 * 1024; // 10MB 86 87export function validateFile( 88 contentType: string, 89 size: number, 90 allowedTypes: string[] 91) { 92 if (!allowedTypes.includes(contentType)) { 93 throw new Error(`Invalid file type: ${contentType}`); 94 } 95 96 if (size > MAX_FILE_SIZE) { 97 throw new Error(`File too large: ${size} bytes (max: ${MAX_FILE_SIZE})`); 98 } 99 100 return true; 101}

Step 5: Create Upload API#

1// app/api/uploads/presign/route.ts 2import { auth } from '@clerk/nextjs'; 3import { NextRequest, NextResponse } from 'next/server'; 4import { nanoid } from 'nanoid'; 5import { 6 getUploadUrl, 7 ALLOWED_IMAGE_TYPES, 8 ALLOWED_DOCUMENT_TYPES, 9 MAX_FILE_SIZE, 10 validateFile, 11} from '@/lib/storage'; 12 13export async function POST(request: NextRequest) { 14 const { userId } = auth(); 15 16 if (!userId) { 17 return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); 18 } 19 20 const { filename, contentType, size, type = 'image' } = await request.json(); 21 22 // Validate input 23 if (!filename || !contentType || !size) { 24 return NextResponse.json( 25 { error: 'Missing required fields' }, 26 { status: 400 } 27 ); 28 } 29 30 // Determine allowed types 31 const allowedTypes = 32 type === 'document' ? ALLOWED_DOCUMENT_TYPES : ALLOWED_IMAGE_TYPES; 33 34 try { 35 validateFile(contentType, size, allowedTypes); 36 } catch (error) { 37 return NextResponse.json( 38 { error: (error as Error).message }, 39 { status: 400 } 40 ); 41 } 42 43 // Generate unique key 44 const extension = filename.split('.').pop(); 45 const key = `uploads/${userId}/${type}s/${nanoid()}.${extension}`; 46 47 try { 48 const { uploadUrl, publicUrl } = await getUploadUrl(key, contentType); 49 50 return NextResponse.json({ 51 uploadUrl, 52 key, 53 publicUrl, 54 }); 55 } catch (error) { 56 console.error('Failed to generate presigned URL:', error); 57 return NextResponse.json( 58 { error: 'Failed to generate upload URL' }, 59 { status: 500 } 60 ); 61 } 62}

Step 6: Create Upload Component#

1// components/FileUpload.tsx 2'use client'; 3 4import { useState, useCallback } from 'react'; 5import { Upload, X, Check, AlertCircle } from 'lucide-react'; 6import { cn } from '@/lib/utils'; 7 8interface FileUploadProps { 9 onUpload: (url: string, key: string) => void; 10 accept?: string; 11 type?: 'image' | 'document'; 12 maxSize?: number; 13 className?: string; 14} 15 16type UploadStatus = 'idle' | 'uploading' | 'success' | 'error'; 17 18export function FileUpload({ 19 onUpload, 20 accept = 'image/*', 21 type = 'image', 22 maxSize = 10 * 1024 * 1024, 23 className, 24}: FileUploadProps) { 25 const [status, setStatus] = useState<UploadStatus>('idle'); 26 const [progress, setProgress] = useState(0); 27 const [error, setError] = useState<string | null>(null); 28 const [preview, setPreview] = useState<string | null>(null); 29 30 const handleUpload = useCallback( 31 async (file: File) => { 32 // Validate file size 33 if (file.size > maxSize) { 34 setError(`File too large. Maximum size is ${maxSize / 1024 / 1024}MB`); 35 setStatus('error'); 36 return; 37 } 38 39 setStatus('uploading'); 40 setProgress(0); 41 setError(null); 42 43 // Create preview for images 44 if (type === 'image') { 45 const reader = new FileReader(); 46 reader.onload = (e) => setPreview(e.target?.result as string); 47 reader.readAsDataURL(file); 48 } 49 50 try { 51 // Get presigned URL 52 const presignResponse = await fetch('/api/uploads/presign', { 53 method: 'POST', 54 headers: { 'Content-Type': 'application/json' }, 55 body: JSON.stringify({ 56 filename: file.name, 57 contentType: file.type, 58 size: file.size, 59 type, 60 }), 61 }); 62 63 if (!presignResponse.ok) { 64 const data = await presignResponse.json(); 65 throw new Error(data.error || 'Failed to get upload URL'); 66 } 67 68 const { uploadUrl, key, publicUrl } = await presignResponse.json(); 69 70 // Upload to S3/R2 with progress 71 await uploadWithProgress(uploadUrl, file, (progress) => { 72 setProgress(progress); 73 }); 74 75 setStatus('success'); 76 onUpload(publicUrl, key); 77 } catch (err) { 78 setError((err as Error).message); 79 setStatus('error'); 80 } 81 }, 82 [maxSize, type, onUpload] 83 ); 84 85 const handleDrop = useCallback( 86 (e: React.DragEvent) => { 87 e.preventDefault(); 88 const file = e.dataTransfer.files[0]; 89 if (file) handleUpload(file); 90 }, 91 [handleUpload] 92 ); 93 94 const handleChange = useCallback( 95 (e: React.ChangeEvent<HTMLInputElement>) => { 96 const file = e.target.files?.[0]; 97 if (file) handleUpload(file); 98 }, 99 [handleUpload] 100 ); 101 102 return ( 103 <div className={cn('relative', className)}> 104 <div 105 onDrop={handleDrop} 106 onDragOver={(e) => e.preventDefault()} 107 className={cn( 108 'border-2 border-dashed rounded-lg p-8 text-center transition-colors', 109 status === 'idle' && 'border-gray-300 hover:border-gray-400', 110 status === 'uploading' && 'border-blue-400 bg-blue-50', 111 status === 'success' && 'border-green-400 bg-green-50', 112 status === 'error' && 'border-red-400 bg-red-50' 113 )} 114 > 115 {status === 'idle' && ( 116 <> 117 <Upload className="mx-auto h-12 w-12 text-gray-400" /> 118 <p className="mt-4 text-sm text-gray-600"> 119 Drag and drop or{' '} 120 <label className="text-blue-600 hover:text-blue-700 cursor-pointer"> 121 browse 122 <input 123 type="file" 124 accept={accept} 125 onChange={handleChange} 126 className="hidden" 127 /> 128 </label> 129 </p> 130 <p className="mt-2 text-xs text-gray-500"> 131 Max size: {maxSize / 1024 / 1024}MB 132 </p> 133 </> 134 )} 135 136 {status === 'uploading' && ( 137 <> 138 {preview && type === 'image' && ( 139 <img 140 src={preview} 141 alt="Preview" 142 className="mx-auto h-24 w-24 object-cover rounded-lg mb-4" 143 /> 144 )} 145 <div className="w-full bg-gray-200 rounded-full h-2"> 146 <div 147 className="bg-blue-600 h-2 rounded-full transition-all duration-300" 148 style={{ width: `${progress}%` }} 149 /> 150 </div> 151 <p className="mt-2 text-sm text-gray-600"> 152 Uploading... {progress}% 153 </p> 154 </> 155 )} 156 157 {status === 'success' && ( 158 <> 159 {preview && type === 'image' && ( 160 <img 161 src={preview} 162 alt="Uploaded" 163 className="mx-auto h-24 w-24 object-cover rounded-lg mb-4" 164 /> 165 )} 166 <Check className="mx-auto h-12 w-12 text-green-500" /> 167 <p className="mt-2 text-sm text-green-600">Upload complete!</p> 168 </> 169 )} 170 171 {status === 'error' && ( 172 <> 173 <AlertCircle className="mx-auto h-12 w-12 text-red-500" /> 174 <p className="mt-2 text-sm text-red-600">{error}</p> 175 <button 176 onClick={() => setStatus('idle')} 177 className="mt-4 text-sm text-blue-600 hover:text-blue-700" 178 > 179 Try again 180 </button> 181 </> 182 )} 183 </div> 184 </div> 185 ); 186} 187 188async function uploadWithProgress( 189 url: string, 190 file: File, 191 onProgress: (progress: number) => void 192): Promise<void> { 193 return new Promise((resolve, reject) => { 194 const xhr = new XMLHttpRequest(); 195 196 xhr.upload.addEventListener('progress', (e) => { 197 if (e.lengthComputable) { 198 const progress = Math.round((e.loaded / e.total) * 100); 199 onProgress(progress); 200 } 201 }); 202 203 xhr.addEventListener('load', () => { 204 if (xhr.status >= 200 && xhr.status < 300) { 205 resolve(); 206 } else { 207 reject(new Error(`Upload failed: ${xhr.statusText}`)); 208 } 209 }); 210 211 xhr.addEventListener('error', () => { 212 reject(new Error('Upload failed')); 213 }); 214 215 xhr.open('PUT', url); 216 xhr.setRequestHeader('Content-Type', file.type); 217 xhr.send(file); 218 }); 219}

Step 7: Add Image Processing#

1// lib/image-processing.ts 2import sharp from 'sharp'; 3 4export interface ProcessedImage { 5 buffer: Buffer; 6 width: number; 7 height: number; 8 format: string; 9} 10 11export async function processImage( 12 input: Buffer, 13 options: { 14 width?: number; 15 height?: number; 16 format?: 'jpeg' | 'png' | 'webp'; 17 quality?: number; 18 } = {} 19): Promise<ProcessedImage> { 20 const { width, height, format = 'webp', quality = 80 } = options; 21 22 let pipeline = sharp(input); 23 24 // Resize if dimensions provided 25 if (width || height) { 26 pipeline = pipeline.resize(width, height, { 27 fit: 'inside', 28 withoutEnlargement: true, 29 }); 30 } 31 32 // Convert format 33 switch (format) { 34 case 'jpeg': 35 pipeline = pipeline.jpeg({ quality }); 36 break; 37 case 'png': 38 pipeline = pipeline.png({ quality }); 39 break; 40 case 'webp': 41 pipeline = pipeline.webp({ quality }); 42 break; 43 } 44 45 const buffer = await pipeline.toBuffer(); 46 const metadata = await sharp(buffer).metadata(); 47 48 return { 49 buffer, 50 width: metadata.width!, 51 height: metadata.height!, 52 format: metadata.format!, 53 }; 54} 55 56export async function generateThumbnail( 57 input: Buffer, 58 size: number = 200 59): Promise<Buffer> { 60 return sharp(input) 61 .resize(size, size, { 62 fit: 'cover', 63 position: 'center', 64 }) 65 .webp({ quality: 80 }) 66 .toBuffer(); 67} 68 69export async function extractImageMetadata(input: Buffer) { 70 const metadata = await sharp(input).metadata(); 71 72 return { 73 width: metadata.width, 74 height: metadata.height, 75 format: metadata.format, 76 size: input.length, 77 hasAlpha: metadata.hasAlpha, 78 }; 79}

Step 8: Create Avatar Upload Component#

1// components/AvatarUpload.tsx 2'use client'; 3 4import { useState } from 'react'; 5import { Camera } from 'lucide-react'; 6import Image from 'next/image'; 7 8interface AvatarUploadProps { 9 currentAvatar?: string | null; 10 onUpload: (url: string) => void; 11} 12 13export function AvatarUpload({ currentAvatar, onUpload }: AvatarUploadProps) { 14 const [uploading, setUploading] = useState(false); 15 const [preview, setPreview] = useState<string | null>(currentAvatar || null); 16 17 const handleChange = async (e: React.ChangeEvent<HTMLInputElement>) => { 18 const file = e.target.files?.[0]; 19 if (!file) return; 20 21 // Validate 22 if (!file.type.startsWith('image/')) { 23 alert('Please select an image file'); 24 return; 25 } 26 27 if (file.size > 5 * 1024 * 1024) { 28 alert('Image must be less than 5MB'); 29 return; 30 } 31 32 // Preview 33 const reader = new FileReader(); 34 reader.onload = (e) => setPreview(e.target?.result as string); 35 reader.readAsDataURL(file); 36 37 setUploading(true); 38 39 try { 40 // Get presigned URL 41 const presignResponse = await fetch('/api/uploads/presign', { 42 method: 'POST', 43 headers: { 'Content-Type': 'application/json' }, 44 body: JSON.stringify({ 45 filename: file.name, 46 contentType: file.type, 47 size: file.size, 48 type: 'avatar', 49 }), 50 }); 51 52 const { uploadUrl, publicUrl } = await presignResponse.json(); 53 54 // Upload 55 await fetch(uploadUrl, { 56 method: 'PUT', 57 body: file, 58 headers: { 'Content-Type': file.type }, 59 }); 60 61 // Update profile 62 await fetch('/api/user/profile', { 63 method: 'PATCH', 64 headers: { 'Content-Type': 'application/json' }, 65 body: JSON.stringify({ avatarUrl: publicUrl }), 66 }); 67 68 onUpload(publicUrl); 69 } catch (error) { 70 console.error('Upload failed:', error); 71 setPreview(currentAvatar || null); 72 } finally { 73 setUploading(false); 74 } 75 }; 76 77 return ( 78 <div className="relative inline-block"> 79 <div className="h-24 w-24 rounded-full overflow-hidden bg-gray-200"> 80 {preview ? ( 81 <Image 82 src={preview} 83 alt="Avatar" 84 width={96} 85 height={96} 86 className="h-full w-full object-cover" 87 /> 88 ) : ( 89 <div className="h-full w-full flex items-center justify-center text-gray-400"> 90 <Camera className="h-8 w-8" /> 91 </div> 92 )} 93 </div> 94 95 <label 96 className={` 97 absolute bottom-0 right-0 h-8 w-8 rounded-full 98 bg-blue-600 text-white flex items-center justify-center 99 cursor-pointer hover:bg-blue-700 transition-colors 100 ${uploading ? 'opacity-50 cursor-not-allowed' : ''} 101 `} 102 > 103 <Camera className="h-4 w-4" /> 104 <input 105 type="file" 106 accept="image/*" 107 onChange={handleChange} 108 disabled={uploading} 109 className="hidden" 110 /> 111 </label> 112 113 {uploading && ( 114 <div className="absolute inset-0 flex items-center justify-center bg-black/50 rounded-full"> 115 <div className="h-6 w-6 border-2 border-white border-t-transparent rounded-full animate-spin" /> 116 </div> 117 )} 118 </div> 119 ); 120}

Step 9: Test Uploads#

  1. Start development server:

    npm run dev
  2. Navigate to a page with the upload component

  3. Test:

    • File selection works
    • Drag and drop works
    • Progress updates correctly
    • Success state shows
    • Error handling works
    • File appears in S3/R2

Verification Checklist#

  • Presigned URLs generate correctly
  • Upload progress tracks accurately
  • File validation works
  • Images display from CDN
  • Error states handled

What You Learned#

  • Presigned URL uploads
  • S3/R2 integration
  • File validation
  • Progress tracking
  • Image processing

Next Steps#