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-uploadsOption 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.comStep 2: Apply Storage Skill#
bootspring skill apply storage/presigned-urlsStep 3: Install Dependencies#
npm install @aws-sdk/client-s3 @aws-sdk/s3-request-presigner sharpStep 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#
-
Start development server:
npm run dev -
Navigate to a page with the upload component
-
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