DEV Community

Atlas Whoff
Atlas Whoff

Posted on

File Uploads Done Right: Presigned S3 URLs, Direct Upload, and Image Processing

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)
Enter fullscreen mode Exit fullscreen mode

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 }
}
Enter fullscreen mode Exit fullscreen mode

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 })
}
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

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' })),
  ])
}
Enter fullscreen mode Exit fullscreen mode

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)