DEV Community

Carlos Oliva Pascual
Carlos Oliva Pascual

Posted on • Originally published at stacknotice.com

AWS S3 File Uploads in Next.js 15: Presigned URLs, Progress & CloudFront (2026)

UploadThing is fast to set up but at scale you're paying per GB stored and transferred. At 10k MAU uploading profile photos, you're looking at $50–200/month for a service that wraps S3 — when S3 itself costs $2–5/month for the same data.

This guide covers direct S3 uploads from Next.js using presigned URLs: no file touching your server, real progress tracking, file type/size validation, multiple file uploads, and CloudFront CDN.

Full guide: stacknotice.com/blog/aws-s3-nextjs-file-upload-2026

How Presigned URLs Work

Client → POST /api/upload/presign → Server generates signed S3 URL
Client → PUT [presigned URL] → S3 directly (server never touches bytes)
Client → POST /api/upload/confirm → Server saves key to database
Enter fullscreen mode Exit fullscreen mode

Your server generates a temporary, signed URL. The client uploads directly to S3. Zero bytes through your server.

Setup

npm install @aws-sdk/client-s3 @aws-sdk/s3-request-presigner
Enter fullscreen mode Exit fullscreen mode
// lib/s3.ts
import { S3Client } from '@aws-sdk/client-s3'

export 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 const S3_BUCKET = process.env.S3_BUCKET_NAME!
export const CDN_DOMAIN = process.env.CLOUDFRONT_DOMAIN!

export const s3KeyToCdnUrl = (key: string) => `${CDN_DOMAIN}/${key}`
Enter fullscreen mode Exit fullscreen mode

S3 CORS Config

Without this, browser uploads are blocked:

[{
  "AllowedHeaders": ["*"],
  "AllowedMethods": ["GET", "PUT", "POST", "DELETE"],
  "AllowedOrigins": ["https://yourapp.com", "http://localhost:3000"],
  "ExposeHeaders": ["ETag"],
  "MaxAgeSeconds": 3000
}]
Enter fullscreen mode Exit fullscreen mode

Presign API Route

// app/api/upload/presign/route.ts
import { PutObjectCommand } from '@aws-sdk/client-s3'
import { getSignedUrl } from '@aws-sdk/s3-request-presigner'
import { s3, S3_BUCKET } from '@/lib/s3'
import { auth } from '@/lib/auth'
import { randomUUID } from 'crypto'

const ALLOWED_TYPES: Record<string, string> = {
  'image/jpeg': 'jpg', 'image/png': 'png', 'image/webp': 'webp',
  'application/pdf': 'pdf',
}
const MAX_FILE_SIZE = 10 * 1024 * 1024 // 10MB

export async function POST(request: Request) {
  const session = await auth()
  if (!session) return Response.json({ error: 'Unauthorized' }, { status: 401 })

  const { fileName, fileType, fileSize, folder = 'uploads' } = await request.json()

  if (!ALLOWED_TYPES[fileType])
    return Response.json({ error: 'File type not allowed' }, { status: 400 })

  if (fileSize > MAX_FILE_SIZE)
    return Response.json({ error: 'File too large (max 10MB)' }, { status: 400 })

  // Key: folder/userId/uuid.ext — scoped to user, no collisions
  const key = `${folder}/${session.userId}/${randomUUID()}.${ALLOWED_TYPES[fileType]}`

  const command = new PutObjectCommand({
    Bucket: S3_BUCKET,
    Key: key,
    ContentType: fileType,
    ContentLength: fileSize,
  })

  const presignedUrl = await getSignedUrl(s3, command, { expiresIn: 900 })

  return Response.json({ presignedUrl, key })
}
Enter fullscreen mode Exit fullscreen mode

Upload Hook with Progress Tracking

fetch() doesn't expose upload progress — XHR does:

// hooks/useS3Upload.ts
export function useS3Upload() {
  const [state, setState] = useState({
    progress: 0,
    status: 'idle' as 'idle' | 'uploading' | 'success' | 'error',
    key: null as string | null,
    error: null as string | null,
  })

  const upload = async (file: File, folder = 'uploads') => {
    setState({ progress: 0, status: 'uploading', key: null, error: null })

    try {
      const res = await fetch('/api/upload/presign', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({ fileName: file.name, fileType: file.type, fileSize: file.size, folder }),
      })
      if (!res.ok) throw new Error((await res.json()).error)

      const { presignedUrl, key } = await res.json()

      // XHR for real progress events
      await new Promise<void>((resolve, reject) => {
        const xhr = new XMLHttpRequest()
        xhr.upload.onprogress = (e) => {
          if (e.lengthComputable)
            setState((p) => ({ ...p, progress: Math.round((e.loaded / e.total) * 100) }))
        }
        xhr.onload = () => xhr.status < 300 ? resolve() : reject(new Error(`S3 error ${xhr.status}`))
        xhr.onerror = () => reject(new Error('Network error'))
        xhr.open('PUT', presignedUrl)
        xhr.setRequestHeader('Content-Type', file.type)
        xhr.send(file)
      })

      setState({ progress: 100, status: 'success', key, error: null })
      return key as string
    } catch (err) {
      const error = err instanceof Error ? err.message : 'Upload failed'
      setState({ progress: 0, status: 'error', key: null, error })
      return null
    }
  }

  return { upload, state, reset: () => setState({ progress: 0, status: 'idle', key: null, error: null }) }
}
Enter fullscreen mode Exit fullscreen mode

Upload Component

export function FileUpload({ folder, onUploadComplete }: {
  folder?: string
  onUploadComplete?: (url: string, key: string) => void
}) {
  const inputRef = useRef<HTMLInputElement>(null)
  const { upload, state, reset } = useS3Upload()

  const handleFile = async (e: React.ChangeEvent<HTMLInputElement>) => {
    const file = e.target.files?.[0]
    if (!file) return
    const key = await upload(file, folder)
    if (key) onUploadComplete?.(s3KeyToCdnUrl(key), key)
  }

  return (
    <div>
      <button onClick={() => inputRef.current?.click()} disabled={state.status === 'uploading'}>
        {state.status === 'uploading' ? `${state.progress}%` : 'Upload file'}
      </button>
      <input ref={inputRef} type="file" onChange={handleFile} className="hidden" />
      {state.status === 'uploading' && (
        <div className="h-1 bg-gray-200 rounded">
          <div className="h-full bg-blue-500 rounded transition-all" style={{ width: `${state.progress}%` }}/>
        </div>
      )}
      {state.error && <p className="text-red-500 text-sm">{state.error} <button onClick={reset}>Retry</button></p>}
    </div>
  )
}
Enter fullscreen mode Exit fullscreen mode

Saving to Database

// app/api/upload/confirm/route.ts
export async function POST(request: Request) {
  const session = await auth()
  if (!session) return Response.json({ error: 'Unauthorized' }, { status: 401 })

  const { key, fileName, fileType, fileSize } = await request.json()

  // Validate ownership: key = folder/userId/uuid.ext
  if (key.split('/')[1] !== session.userId)
    return Response.json({ error: 'Forbidden' }, { status: 403 })

  const [file] = await db.insert(userFiles)
    .values({ userId: session.userId, s3Key: key, fileName, fileType, fileSize, url: s3KeyToCdnUrl(key) })
    .returning()

  return Response.json({ file })
}
Enter fullscreen mode Exit fullscreen mode

CloudFront CDN

Files served directly from S3 are slow for users outside your bucket's region. CloudFront caches at 400+ edge locations — 10–50ms vs 200–500ms.

  1. CloudFront → Create distribution → Origin: your S3 bucket
  2. Restrict bucket access to CloudFront only
  3. Update bucket policy to allow CloudFront's ARN

Now s3KeyToCdnUrl() returns CloudFront URLs — bucket stays private, files serve globally fast.

S3 vs UploadThing

S3 direct UploadThing
Setup ~2 hours ~20 min
Cost at scale $0.023/GB/month $0.10/GB + fees
Progress Custom (XHR) Built-in
Self-hosted

Use UploadThing when moving fast. Switch to direct S3 when your storage bill exceeds $20/month.


Full guide with multiple file uploads, delete endpoint, CloudFront setup, and env checklist: stacknotice.com/blog/aws-s3-nextjs-file-upload-2026

Top comments (0)