How to Handle File Uploads in Next.js (2026)
File uploads are deceptively complex — size limits, security, storage, CDN delivery. Here's the complete guide for Next.js in 2026.
The Options
| Solution | Best For | Cost |
|---|---|---|
| UploadThing | Fastest setup, small files | Free (2GB), $10/mo (100GB) |
| Vercel Blob | Vercel users, simple storage | Free (256MB), $0.15/GB |
| Cloudflare R2 | Cheapest at scale, zero egress | Free (10GB), $0.015/GB |
| Supabase Storage | Supabase users | Free (1GB), included in plan |
| AWS S3 | Enterprise, maximum control | $0.023/GB + egress |
Option 1: UploadThing (Easiest)
npm install uploadthing @uploadthing/react
Server Setup
// app/api/uploadthing/core.ts
import { createUploadthing } from 'uploadthing/next'
const f = createUploadthing()
export const ourFileRouter = {
imageUploader: f({ image: { maxFileSize: '4MB', maxFileCount: 4 } })
.middleware(async ({ req }) => {
const user = await auth()
if (!user) throw new Error('Unauthorized')
return { userId: user.id }
})
.onUploadComplete(async ({ metadata, file }) => {
console.log('Upload complete:', file.url)
// Save to database
await db.insert(uploads).values({
userId: metadata.userId,
url: file.url,
name: file.name,
})
}),
}
React Component
import { UploadButton } from '@uploadthing/react'
function ProfileForm() {
return (
<UploadButton
endpoint="imageUploader"
onClientUploadComplete={(res) => {
console.log('Files:', res)
setAvatarUrl(res[0].url)
}}
onUploadError={(error) => {
alert(`Error: ${error.message}`)
}}
/>
)
}
5 minutes to working file uploads. Pre-built components, CDN delivery, and type-safe API.
Option 2: Presigned URLs + R2/S3 (Production)
For more control, generate presigned URLs and upload directly to storage:
Generate Presigned URL (Server)
// app/api/upload/route.ts
import { S3Client, PutObjectCommand } from '@aws-sdk/client-s3'
import { getSignedUrl } from '@aws-sdk/s3-request-presigner'
const s3 = new S3Client({
region: 'auto',
endpoint: process.env.R2_ENDPOINT,
credentials: {
accessKeyId: process.env.R2_ACCESS_KEY!,
secretAccessKey: process.env.R2_SECRET_KEY!,
},
})
export async function POST(req: Request) {
const { filename, contentType } = await req.json()
const key = `uploads/${crypto.randomUUID()}-${filename}`
const command = new PutObjectCommand({
Bucket: process.env.R2_BUCKET,
Key: key,
ContentType: contentType,
})
const presignedUrl = await getSignedUrl(s3, command, { expiresIn: 600 })
return Response.json({ presignedUrl, key })
}
Upload from Client
async function uploadFile(file: File) {
// Step 1: Get presigned URL
const { presignedUrl, key } = await fetch('/api/upload', {
method: 'POST',
body: JSON.stringify({ filename: file.name, contentType: file.type }),
}).then(r => r.json())
// Step 2: Upload directly to R2/S3 (bypasses your server)
await fetch(presignedUrl, {
method: 'PUT',
body: file,
headers: { 'Content-Type': file.type },
})
// Step 3: Save reference in database
return `${process.env.NEXT_PUBLIC_CDN_URL}/${key}`
}
Pros: Files go directly to storage (not through your server). Scalable. Cheap.
Option 3: Vercel Blob
import { put } from '@vercel/blob'
export async function POST(req: Request) {
const form = await req.formData()
const file = form.get('file') as File
const blob = await put(file.name, file, { access: 'public' })
return Response.json({ url: blob.url })
}
Simplest for Vercel users. Automatic CDN. But more expensive per GB than R2.
Image Optimization
With Next.js Image Component
import Image from 'next/image'
<Image
src={uploadedImageUrl}
alt="User avatar"
width={200}
height={200}
quality={80}
/>
Next.js automatically optimizes, resizes, and converts to WebP.
With Cloudflare Image Resizing
https://yourdomain.com/cdn-cgi/image/width=200,height=200,quality=80/uploads/avatar.jpg
File Validation
Server-Side (Critical)
const ALLOWED_TYPES = ['image/jpeg', 'image/png', 'image/webp', 'application/pdf']
const MAX_SIZE = 10 * 1024 * 1024 // 10MB
function validateFile(file: File) {
if (!ALLOWED_TYPES.includes(file.type)) {
throw new Error('Invalid file type')
}
if (file.size > MAX_SIZE) {
throw new Error('File too large')
}
}
Client-Side (UX)
<input
type="file"
accept="image/jpeg,image/png,image/webp"
onChange={(e) => {
const file = e.target.files?.[0]
if (file && file.size > 10 * 1024 * 1024) {
alert('File must be under 10MB')
return
}
handleUpload(file)
}}
/>
Always validate on the server. Client-side validation is for UX only.
Drag and Drop Upload
function DropZone({ onUpload }) {
const [isDragging, setIsDragging] = useState(false)
return (
<div
onDragOver={(e) => { e.preventDefault(); setIsDragging(true) }}
onDragLeave={() => setIsDragging(false)}
onDrop={(e) => {
e.preventDefault()
setIsDragging(false)
const files = Array.from(e.dataTransfer.files)
files.forEach(onUpload)
}}
className={`border-2 border-dashed rounded-lg p-8 text-center
${isDragging ? 'border-blue-500 bg-blue-50' : 'border-gray-300'}`}
>
Drag files here or <button>browse</button>
</div>
)
}
FAQ
Should files go through my server?
No. Use presigned URLs to upload directly to storage. Your server generates the URL, the client uploads directly. Saves bandwidth and avoids request size limits.
How do I handle large files?
Use multipart uploads. The S3 SDK handles this automatically for files >5MB. Set appropriate timeout and chunk size.
Which storage should I use?
UploadThing for speed. R2 for cost. Vercel Blob for simplicity. S3 for enterprise.
Bottom Line
Use UploadThing for fast setup (5 minutes). Use presigned URLs + Cloudflare R2 for production scale (cheapest, most control). Always validate files server-side. Use Next.js Image component for automatic optimization.