File uploads are deceptively complex. A naive implementation using multipart/form-data works until you hit file size limits, memory pressure, slow uploads, or storage costs. Here's how to do it right: direct-to-S3 uploads, signed URLs, and image processing pipelines.
Direct Upload Architecture
Never proxy file uploads through your server. The correct flow:
1. Client requests a signed upload URL from your API
2. Your API generates a presigned S3 URL (15 min expiry)
3. Client uploads directly to S3 using the presigned URL
4. Client notifies your API that the upload is complete
5. Your API processes the file (resize, scan, index)
This keeps your server out of the data path entirely.
Generating Presigned Upload URLs
import { S3Client, PutObjectCommand } from '@aws-sdk/client-s3'
import { getSignedUrl } from '@aws-sdk/s3-request-presigner'
const s3 = new S3Client({
region: process.env.AWS_REGION!,
credentials: {
accessKeyId: process.env.AWS_ACCESS_KEY_ID!,
secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY!,
},
})
export async function getUploadUrl({
fileName,
fileType,
userId,
}: {
fileName: string
fileType: string
userId: string
}) {
// Sanitize and namespace the key
const key = `uploads/${userId}/${Date.now()}-${fileName.replace(/[^a-zA-Z0-9.-]/g, '_')}`
const command = new PutObjectCommand({
Bucket: process.env.S3_BUCKET!,
Key: key,
ContentType: fileType,
Metadata: { userId },
})
const uploadUrl = await getSignedUrl(s3, command, { expiresIn: 900 }) // 15 min
return { uploadUrl, key }
}
API Route
// app/api/upload/route.ts
import { z } from 'zod'
const ALLOWED_TYPES = ['image/jpeg', 'image/png', 'image/webp', 'application/pdf']
const MAX_SIZE = 10 * 1024 * 1024 // 10MB
const schema = z.object({
fileName: z.string().max(255),
fileType: z.string().refine(t => ALLOWED_TYPES.includes(t), 'File type not allowed'),
fileSize: z.number().max(MAX_SIZE, 'File too large'),
})
export async function POST(req: Request) {
const session = await getServerSession()
if (!session) return Response.json({ error: 'Unauthorized' }, { status: 401 })
const body = schema.parse(await req.json())
const { uploadUrl, key } = await getUploadUrl({ ...body, userId: session.user.id })
// Save pending upload record
const upload = await db.upload.create({
data: { key, userId: session.user.id, status: 'pending', mimeType: body.fileType },
})
return Response.json({ uploadUrl, uploadId: upload.id })
}
Client Upload
async function uploadFile(file: File) {
// Step 1: Get presigned URL
const { uploadUrl, uploadId } = await fetch('/api/upload', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
fileName: file.name,
fileType: file.type,
fileSize: file.size,
}),
}).then(r => r.json())
// Step 2: Upload directly to S3
await fetch(uploadUrl, {
method: 'PUT',
headers: { 'Content-Type': file.type },
body: file,
})
// Step 3: Confirm upload
await fetch(`/api/upload/${uploadId}/confirm`, { method: 'POST' })
return uploadId
}
Image Processing with Sharp
// Process after S3 upload confirmation
import sharp from 'sharp'
import { GetObjectCommand } from '@aws-sdk/client-s3'
async function processImage(key: string) {
// Download from S3
const { Body } = await s3.send(new GetObjectCommand({
Bucket: process.env.S3_BUCKET!,
Key: key,
}))
const buffer = Buffer.from(await Body!.transformToByteArray())
// Generate variants
const [thumbnail, medium] = await Promise.all([
sharp(buffer).resize(200, 200, { fit: 'cover' }).webp({ quality: 80 }).toBuffer(),
sharp(buffer).resize(800, 800, { fit: 'inside' }).webp({ quality: 85 }).toBuffer(),
])
// Upload variants back to S3
await Promise.all([
s3.send(new PutObjectCommand({ Bucket: process.env.S3_BUCKET!, Key: `${key}-thumb.webp`, Body: thumbnail, ContentType: 'image/webp' })),
s3.send(new PutObjectCommand({ Bucket: process.env.S3_BUCKET!, Key: `${key}-medium.webp`, Body: medium, ContentType: 'image/webp' })),
])
}
The Ship Fast Skill Pack at whoffagents.com includes a /upload skill that scaffolds presigned URL generation, S3 upload flow, and Sharp image processing for your stack. $49 one-time.
Top comments (0)