UploadThing is the fastest way to add file uploads to a Next.js app. But at scale, you're paying per GB stored and per GB transferred. At 10k monthly active users uploading profile photos and documents, you're looking at $50–200/month for a service that wraps S3 — when S3 itself would cost $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 and size validation, multiple file uploads, and a CloudFront CDN in front of your bucket. Production-ready code from the start.
How Presigned URLs Work
The naive approach — proxying uploads through your server — has two problems:
- Your serverless function has a body size limit (4.5MB on Vercel)
- Every byte uploaded consumes your server bandwidth and compute time
Presigned URLs flip this: your server generates a temporary, signed S3 URL with a 15-minute expiry. The client uploads directly to S3 using that URL. Your server never touches the file bytes.
Client → POST /api/upload/presign → Server generates presigned URL
Client → PUT [presigned S3 URL] → S3 (direct, no server involved)
Client → POST /api/upload/confirm → Server saves S3 key to database
Three requests: get the URL, upload the file, save the reference. Simple.
AWS Setup
First, create an S3 bucket and IAM user. In the AWS console:
- S3 → Create bucket → choose a region → Block all public access (we'll use CloudFront)
- IAM → Create user → attach this policy:
{
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Action": [
"s3:PutObject",
"s3:GetObject",
"s3:DeleteObject"
],
"Resource": "arn:aws:s3:::your-bucket-name/*"
}
]
}- Generate access keys for the IAM user.
Configure S3 CORS — without this, browser uploads will be blocked:
[
{
"AllowedHeaders": ["*"],
"AllowedMethods": ["GET", "PUT", "POST", "DELETE"],
"AllowedOrigins": ["https://yourapp.com", "http://localhost:3000"],
"ExposeHeaders": ["ETag"],
"MaxAgeSeconds": 3000
}
]Add your credentials to .env.local:
AWS_ACCESS_KEY_ID=AKIA...
AWS_SECRET_ACCESS_KEY=your-secret-key
AWS_REGION=us-east-1
S3_BUCKET_NAME=your-bucket-name
CLOUDFRONT_DOMAIN=https://d1234567890.cloudfront.netInstalling the AWS SDK
npm install @aws-sdk/client-s3 @aws-sdk/s3-request-presignerCreate the S3 client singleton:
// 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!
// Convert S3 key to CDN URL
export function s3KeyToCdnUrl(key: string): string {
return `${CDN_DOMAIN}/${key}`
}The Presign API Route
This route validates the request, generates the presigned URL, and returns it to the client:
// 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'
// Allowed file types and their MIME types
const ALLOWED_TYPES: Record<string, string> = {
'image/jpeg': 'jpg',
'image/png': 'png',
'image/webp': 'webp',
'image/gif': 'gif',
'application/pdf': 'pdf',
}
const MAX_FILE_SIZE = 10 * 1024 * 1024 // 10MB
interface PresignRequest {
fileName: string
fileType: string
fileSize: number
folder?: string // e.g. 'avatars', 'documents', 'posts'
}
export async function POST(request: Request) {
const session = await auth()
if (!session) {
return Response.json({ error: 'Unauthorized' }, { status: 401 })
}
const body: PresignRequest = await request.json()
const { fileName, fileType, fileSize, folder = 'uploads' } = body
// Validate file type
if (!ALLOWED_TYPES[fileType]) {
return Response.json(
{ error: `File type not allowed. Accepted: ${Object.keys(ALLOWED_TYPES).join(', ')}` },
{ status: 400 }
)
}
// Validate file size
if (fileSize > MAX_FILE_SIZE) {
return Response.json(
{ error: `File too large. Maximum size: ${MAX_FILE_SIZE / 1024 / 1024}MB` },
{ status: 400 }
)
}
// Build S3 key: folder/userId/uuid.ext
const extension = ALLOWED_TYPES[fileType]
const key = `${folder}/${session.userId}/${randomUUID()}.${extension}`
// Generate presigned URL (expires in 15 minutes)
const command = new PutObjectCommand({
Bucket: S3_BUCKET,
Key: key,
ContentType: fileType,
ContentLength: fileSize,
// Enforce the content type — prevents uploading a different file type
// using the same presigned URL
})
const presignedUrl = await getSignedUrl(s3, command, { expiresIn: 900 })
return Response.json({ presignedUrl, key })
}The key pattern folder/userId/uuid.ext is important — it scopes files to the user (so they can't overwrite each other's files), and the UUID prevents filename collisions.
The Client-Side Upload Hook
Build a reusable hook that handles the full upload flow with progress tracking:
// hooks/useS3Upload.ts
import { useState, useCallback } from 'react'
interface UploadState {
progress: number // 0–100
status: 'idle' | 'uploading' | 'success' | 'error'
key: string | null
error: string | null
}
interface UseS3UploadReturn {
upload: (file: File, folder?: string) => Promise<string | null>
state: UploadState
reset: () => void
}
export function useS3Upload(): UseS3UploadReturn {
const [state, setState] = useState<UploadState>({
progress: 0,
status: 'idle',
key: null,
error: null,
})
const reset = useCallback(() => {
setState({ progress: 0, status: 'idle', key: null, error: null })
}, [])
const upload = useCallback(async (file: File, folder = 'uploads'): Promise<string | null> => {
setState({ progress: 0, status: 'uploading', key: null, error: null })
try {
// Step 1: Get presigned URL from our API
const presignResponse = 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 (!presignResponse.ok) {
const err = await presignResponse.json()
throw new Error(err.error ?? 'Failed to get upload URL')
}
const { presignedUrl, key } = await presignResponse.json()
// Step 2: Upload directly to S3 with XMLHttpRequest for progress tracking
// fetch() doesn't expose upload progress — XHR does
await uploadWithProgress(file, presignedUrl, (progress) => {
setState((prev) => ({ ...prev, progress }))
})
setState({ progress: 100, status: 'success', key, error: null })
return key
} catch (error) {
const message = error instanceof Error ? error.message : 'Upload failed'
setState({ progress: 0, status: 'error', key: null, error: message })
return null
}
}, [])
return { upload, state, reset }
}
function uploadWithProgress(
file: File,
url: string,
onProgress: (progress: number) => void
): Promise<void> {
return new Promise((resolve, reject) => {
const xhr = new XMLHttpRequest()
xhr.upload.addEventListener('progress', (event) => {
if (event.lengthComputable) {
const progress = Math.round((event.loaded / event.total) * 100)
onProgress(progress)
}
})
xhr.addEventListener('load', () => {
if (xhr.status >= 200 && xhr.status < 300) {
resolve()
} else {
reject(new Error(`S3 upload failed with status ${xhr.status}`))
}
})
xhr.addEventListener('error', () => reject(new Error('Network error during upload')))
xhr.addEventListener('abort', () => reject(new Error('Upload aborted')))
xhr.open('PUT', url)
xhr.setRequestHeader('Content-Type', file.type)
xhr.send(file)
})
}The XMLHttpRequest detour is intentional — fetch() doesn't support upload progress events. XHR does.
File Upload Component with Progress
// components/FileUpload.tsx
'use client'
import { useRef, useState } from 'react'
import { useS3Upload } from '@/hooks/useS3Upload'
import { s3KeyToCdnUrl } from '@/lib/s3'
interface FileUploadProps {
folder?: string
accept?: string
maxSizeMB?: number
onUploadComplete?: (url: string, key: string) => void
label?: string
}
export function FileUpload({
folder = 'uploads',
accept = 'image/*',
maxSizeMB = 10,
onUploadComplete,
label = 'Upload file',
}: FileUploadProps) {
const inputRef = useRef<HTMLInputElement>(null)
const [preview, setPreview] = useState<string | null>(null)
const { upload, state, reset } = useS3Upload()
const handleFileSelect = async (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0]
if (!file) return
// Client-side size check before hitting the API
if (file.size > maxSizeMB * 1024 * 1024) {
alert(`File too large. Maximum size is ${maxSizeMB}MB`)
return
}
// Show image preview immediately
if (file.type.startsWith('image/')) {
const reader = new FileReader()
reader.onload = (e) => setPreview(e.target?.result as string)
reader.readAsDataURL(file)
}
const key = await upload(file, folder)
if (key && onUploadComplete) {
onUploadComplete(s3KeyToCdnUrl(key), key)
}
}
return (
<div className="flex flex-col gap-3">
{preview && (
<img src={preview} alt="Preview" className="h-32 w-32 rounded-lg object-cover" />
)}
<button
type="button"
onClick={() => inputRef.current?.click()}
disabled={state.status === 'uploading'}
className="rounded-lg border-2 border-dashed border-gray-300 px-6 py-4 text-sm text-gray-500 hover:border-blue-400 hover:text-blue-500 disabled:opacity-50"
>
{state.status === 'uploading' ? 'Uploading...' : label}
</button>
<input
ref={inputRef}
type="file"
accept={accept}
onChange={handleFileSelect}
className="hidden"
/>
{state.status === 'uploading' && (
<div className="space-y-1">
<div className="flex justify-between text-xs text-gray-500">
<span>Uploading...</span>
<span>{state.progress}%</span>
</div>
<div className="h-1.5 w-full overflow-hidden rounded-full bg-gray-200">
<div
className="h-full rounded-full bg-blue-500 transition-all duration-200"
style={{ width: `${state.progress}%` }}
/>
</div>
</div>
)}
{state.status === 'success' && (
<p className="text-sm text-green-600">Upload complete</p>
)}
{state.status === 'error' && (
<div className="flex items-center gap-2">
<p className="text-sm text-red-600">{state.error}</p>
<button
onClick={reset}
className="text-xs text-blue-500 underline"
>
Try again
</button>
</div>
)}
</div>
)
}Multiple File Upload
For uploading several files at once (a photo gallery, bulk document import), run uploads in parallel with a concurrency limit:
// hooks/useS3MultiUpload.ts
import { useState, useCallback } from 'react'
interface FileUploadResult {
file: File
key: string | null
error: string | null
progress: number
}
export function useS3MultiUpload() {
const [uploads, setUploads] = useState<Map<string, FileUploadResult>>(new Map())
const uploadFiles = useCallback(async (files: File[], folder = 'uploads') => {
// Initialize state for all files
const initial = new Map<string, FileUploadResult>()
files.forEach((file) => {
initial.set(file.name, { file, key: null, error: null, progress: 0 })
})
setUploads(initial)
// Upload with max 3 concurrent uploads
const concurrency = 3
const results: string[] = []
for (let i = 0; i < files.length; i += concurrency) {
const batch = files.slice(i, i + concurrency)
const batchResults = await Promise.all(
batch.map(async (file) => {
try {
const presignResponse = 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 (!presignResponse.ok) throw new Error('Failed to get upload URL')
const { presignedUrl, key } = await presignResponse.json()
await uploadWithProgress(file, presignedUrl, (progress) => {
setUploads((prev) => {
const next = new Map(prev)
const current = next.get(file.name)!
next.set(file.name, { ...current, progress })
return next
})
})
setUploads((prev) => {
const next = new Map(prev)
next.set(file.name, { file, key, error: null, progress: 100 })
return next
})
return key as string
} catch (err) {
const error = err instanceof Error ? err.message : 'Upload failed'
setUploads((prev) => {
const next = new Map(prev)
next.set(file.name, { file, key: null, error, progress: 0 })
return next
})
return null
}
})
)
results.push(...(batchResults.filter(Boolean) as string[]))
}
return results
}, [])
const successCount = Array.from(uploads.values()).filter((u) => u.key !== null).length
const errorCount = Array.from(uploads.values()).filter((u) => u.error !== null).length
const totalProgress =
uploads.size > 0
? Array.from(uploads.values()).reduce((sum, u) => sum + u.progress, 0) / uploads.size
: 0
return { uploadFiles, uploads, successCount, errorCount, totalProgress }
}Saving File References to the Database
After a successful upload, save the S3 key to your database. Use Neon Postgres with Drizzle:
// app/api/upload/confirm/route.ts
import { auth } from '@/lib/auth'
import { db } from '@/lib/db'
import { userFiles } from '@/db/schema'
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 the key belongs to the current user
// Key format: folder/userId/uuid.ext
const keyParts = key.split('/')
if (keyParts[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 })
}// db/schema.ts (relevant table)
export const userFiles = pgTable('user_files', {
id: text('id').primaryKey().$defaultFn(() => crypto.randomUUID()),
userId: text('user_id').notNull().references(() => users.id),
s3Key: text('s3_key').notNull().unique(),
fileName: text('file_name').notNull(),
fileType: text('file_type').notNull(),
fileSize: integer('file_size').notNull(),
url: text('url').notNull(),
createdAt: timestamp('created_at').defaultNow().notNull(),
})CloudFront CDN Setup
Serving files directly from S3 is slow for users outside your bucket's region. CloudFront caches files at ~400 edge locations globally — images load in 10–50ms instead of 200–500ms.
- CloudFront → Create distribution → Origin: your S3 bucket
- Restrict bucket access: Yes (CloudFront only, not public S3)
- Viewer protocol policy: Redirect HTTP to HTTPS
- Add a cache policy: Cache-Control headers from origin
Update the S3 bucket policy to only allow CloudFront:
{
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Principal": {
"Service": "cloudfront.amazonaws.com"
},
"Action": "s3:GetObject",
"Resource": "arn:aws:s3:::your-bucket-name/*",
"Condition": {
"StringEquals": {
"AWS:SourceArn": "arn:aws:cloudfront::YOUR_ACCOUNT_ID:distribution/YOUR_DIST_ID"
}
}
}
]
}Now your s3KeyToCdnUrl function returns CloudFront URLs, files are cached globally, and the S3 bucket itself is private.
Deleting Files
When a user deletes a file, remove it from S3 and the database:
// app/api/upload/[key]/route.ts
import { DeleteObjectCommand } from '@aws-sdk/client-s3'
import { s3, S3_BUCKET } from '@/lib/s3'
import { auth } from '@/lib/auth'
import { db } from '@/lib/db'
import { userFiles } from '@/db/schema'
import { and, eq } from 'drizzle-orm'
export async function DELETE(
request: Request,
{ params }: { params: Promise<{ key: string }> }
) {
const session = await auth()
if (!session) return Response.json({ error: 'Unauthorized' }, { status: 401 })
const { key } = await params
const decodedKey = decodeURIComponent(key)
// Verify ownership before deleting
const file = await db.query.userFiles.findFirst({
where: and(
eq(userFiles.s3Key, decodedKey),
eq(userFiles.userId, session.userId)
),
})
if (!file) return Response.json({ error: 'File not found' }, { status: 404 })
// Delete from S3
await s3.send(new DeleteObjectCommand({
Bucket: S3_BUCKET,
Key: decodedKey,
}))
// Delete from database
await db.delete(userFiles).where(eq(userFiles.id, file.id))
return Response.json({ success: true })
}Environment Variables Checklist
# Required
AWS_ACCESS_KEY_ID=AKIA...
AWS_SECRET_ACCESS_KEY=your-secret
AWS_REGION=us-east-1
S3_BUCKET_NAME=your-bucket
# Optional but recommended for production
CLOUDFRONT_DOMAIN=https://d1234.cloudfront.netNever commit these. Add all five to your Vercel environment variables under Project → Settings → Environment Variables.
S3 vs UploadThing: When to Use Each
| Factor | S3 direct | UploadThing |
|---|---|---|
| Setup time | ~2 hours | ~20 minutes |
| Cost at scale | $0.023/GB/month | $0.10/GB + request fees |
| Progress tracking | Custom (XHR) | Built-in |
| File transformations | Manual (Lambda) | Built-in (image resize) |
| Background removal | Manual | Not available |
| Self-hosted | Yes | No |
Use UploadThing when you're moving fast and cost isn't a concern yet. Switch to direct S3 when your storage bill exceeds $20/month or when you need more control over where files live.
Summary
Direct S3 uploads with presigned URLs is the production pattern that most scaling apps end up at. The flow:
- Presign route validates file type/size and returns a temporary S3 URL — server never touches the bytes
- XHR upload from the browser directly to S3 with real progress events
- Confirm route saves the S3 key and CDN URL to your database after upload completes
- CloudFront in front of S3 for global CDN performance and private bucket security
- Delete route removes from both S3 and database with ownership check
The extra setup compared to UploadThing pays off once you're storing gigabytes of user data.