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
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
// 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}`
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
}]
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 })
}
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 }) }
}
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>
)
}
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 })
}
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.
- CloudFront → Create distribution → Origin: your S3 bucket
- Restrict bucket access to CloudFront only
- 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)