File Upload & Storage
Client-side uploads, server processing, and cloud storage integration
Upload via Route Handler
Simplest approach — write the file to disk locally (development only).
// app/api/upload/route.ts
import { NextRequest, NextResponse } from "next/server"
import { writeFile, mkdir } from "fs/promises"
import path from "path"
export async function POST(request: NextRequest) {
const formData = await request.formData()
const file = formData.get("file") as File | null
if (!file) {
return NextResponse.json({ error: "No file provided" }, { status: 400 })
}
// Validate type
if (!file.type.startsWith("image/")) {
return NextResponse.json({ error: "Only images allowed" }, { status: 400 })
}
// Validate size — 5MB limit
if (file.size > 5 * 1024 * 1024) {
return NextResponse.json(
{ error: "File too large (max 5MB)" },
{ status: 400 }
)
}
const bytes = await file.arrayBuffer()
const buffer = Buffer.from(bytes)
const uploadDir = path.join(process.cwd(), "public/uploads")
await mkdir(uploadDir, { recursive: true })
const filename = `${Date.now()}-${file.name}`
await writeFile(path.join(uploadDir, filename), buffer)
return NextResponse.json({ url: `/uploads/${filename}` })
}
UploadThing Integration
UploadThing handles file storage, CDN delivery, and image optimization — no infrastructure to manage.
// app/api/uploadthing/core.ts
import { createUploadthing, type FileRouter } from "uploadthing/next"
import { auth } from "@/lib/auth"
const f = createUploadthing()
export const ourFileRouter = {
avatarUpload: f({ image: { maxFileSize: "4MB", maxFileCount: 1 } })
.middleware(async () => {
const user = await auth()
if (!user) throw new Error("Unauthorized")
return { userId: user.id }
})
.onUploadComplete(async ({ metadata, file }) => {
console.log("Upload complete for userId:", metadata.userId)
console.log("File URL:", file.url)
}),
postImage: f({ image: { maxFileSize: "16MB", maxFileCount: 9 } })
.middleware(async () => {
const user = await auth()
if (!user) throw new Error("Unauthorized")
return { userId: user.id }
})
.onUploadComplete(async ({ file }) => {
return { uploadedBy: file.name }
}),
} satisfies FileRouter
export type OurFileRouter = typeof ourFileRouter
UploadThing generates optimized image URLs (WebP, resized variants). Store
the returned file.url in your database — not a copy of the file.
Client-Side Upload with Progress
Track upload progress using XMLHttpRequest or fetch with ReadableStream. Below uses XHR for progress event support.
"use client"
import { useState } from "react"
export function UploadWithProgress() {
const [progress, setProgress] = useState(0)
const [url, setUrl] = useState<string | null>(null)
async function handleUpload(e: React.ChangeEvent<HTMLInputElement>) {
const file = e.target.files?.[0]
if (!file) return
const formData = new FormData()
formData.append("file", file)
const xhr = new XMLHttpRequest()
xhr.upload.addEventListener("progress", (event) => {
if (event.lengthComputable) {
setProgress(Math.round((event.loaded / event.total) * 100))
}
})
xhr.addEventListener("load", () => {
const { url } = JSON.parse(xhr.responseText)
setUrl(url)
})
xhr.open("POST", "/api/upload")
xhr.send(formData)
}
return (
<div>
<input type="file" onChange={handleUpload} />
{progress > 0 && (
<div className="h-2.5 w-full rounded-full bg-gray-200">
<div
className="h-2.5 rounded-full bg-blue-600"
style={{ width: `${progress}%` }}
/>
</div>
)}
{url && (
<img
src={url}
alt="Uploaded"
className="mt-4 h-32 w-32 rounded object-cover"
/>
)}
</div>
)
}
Drag-and-Drop Zone with Preview
"use client"
import { useState, useRef } from "react"
export function DropZone() {
const [preview, setPreview] = useState<string | null>(null)
const [isDragging, setIsDragging] = useState(false)
const inputRef = useRef<HTMLInputElement>(null)
function handleDrop(e: React.DragEvent) {
e.preventDefault()
setIsDragging(false)
const file = e.dataTransfer.files[0]
if (file && file.type.startsWith("image/")) {
setPreview(URL.createObjectURL(file))
}
}
async function handleUpload() {
if (!preview) return
// Upload logic — same as UploadWithProgress
}
return (
<div
className={`cursor-pointer rounded-lg border-2 border-dashed p-8 text-center ${isDragging ? "border-blue-500 bg-blue-50" : "border-gray-300"}`}
onDragOver={(e) => {
e.preventDefault()
setIsDragging(true)
}}
onDragLeave={() => setIsDragging(false)}
onDrop={handleDrop}
onClick={() => inputRef.current?.click()}
>
<input
ref={inputRef}
type="file"
accept="image/*"
className="hidden"
onChange={(e) => {
const file = e.target.files?.[0]
if (file) setPreview(URL.createObjectURL(file))
}}
/>
{preview ? (
<img src={preview} alt="Preview" className="mx-auto max-h-48 rounded" />
) : (
<p className="text-gray-500">Drag an image here or click to browse</p>
)}
{preview && (
<button
onClick={handleUpload}
className="mt-4 rounded bg-blue-600 px-4 py-2 text-white hover:bg-blue-700"
>
Upload
</button>
)}
</div>
)
}
S3 Direct Upload (Presigned URL)
For large files, upload directly to S3 from the browser — server generates a temporary presigned URL.
// app/api/upload/s3-url/route.ts
import { NextRequest, NextResponse } from "next/server"
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 POST(request: NextRequest) {
const { filename, contentType } = await request.json()
const key = `uploads/${Date.now()}-${filename}`
const command = new PutObjectCommand({
Bucket: process.env.S3_BUCKET!,
Key: key,
ContentType: contentType,
})
const url = await getSignedUrl(s3, command, { expiresIn: 3600 })
return NextResponse.json({ url, key })
}
// Client — uploads directly to S3
async function uploadToS3(file: File) {
const { url, key } = await fetch("/api/upload/s3-url", {
method: "POST",
body: JSON.stringify({ filename: file.name, contentType: file.type }),
}).then((r) => r.json())
await fetch(url, {
method: "PUT",
body: file,
headers: { "Content-Type": file.type },
})
return key
}
Presigned URLs expire (set via expiresIn). For very large files (100MB+), consider multipart uploads or client-side chunking with a resumable upload library like tus-js-client.
See It In Action
1"use client"2import { useState, useRef } from "react"3 4export default function AvatarUpload() {5const [preview, setPreview] = useState<string | null>(null)6const [progress, setProgress] = useState(0)7const [error, setError] = useState<string | null>(null)8const fileRef = useRef<File | null>(null)9 10function validateFile(file: File): string | null {11const allowedTypes = ["image/jpeg", "image/png", "image/webp", "image/gif"]12if (!allowedTypes.includes(file.type)) {13return "Only JPEG, PNG, WebP, and GIF images are allowed"14}15const maxSize = 5 _ 1024 _ 102416if (file.size > maxSize) {17return "File too large — max 5MB"18}19return null20}21 22function handleDrop(e: React.DragEvent) {23e.preventDefault()24const file = e.dataTransfer.files[0]25if (!file) return26const err = validateFile(file)27if (err) { setError(err); return }28setError(null)29fileRef.current = file30setPreview(URL.createObjectURL(file))31}32 33function handleFileSelect(e: React.ChangeEvent<HTMLInputElement>) {34const file = e.target.files?.[0]35if (!file) return36const err = validateFile(file)37if (err) { setError(err); return }38setError(null)39fileRef.current = file40setPreview(URL.createObjectURL(file))41}42 43async function handleUpload() {44const file = fileRef.current45if (!file) return46 47 const formData = new FormData()48 formData.append("file", file)49 50 const xhr = new XMLHttpRequest()51 xhr.upload.addEventListener("progress", (e) => {52 if (e.lengthComputable) {53 setProgress(Math.round((e.loaded / e.total) * 100))54 }55 })56 xhr.addEventListener("load", () => setProgress(100))57 xhr.addEventListener("error", () => setError("Upload failed"))58 xhr.open("POST", "/api/upload")59 xhr.send(formData)60 61}62 63function handleDragOver(e: React.DragEvent) {64e.preventDefault()65}66 67return (68 69<div className="space-y-4">70<div71 className="flex cursor-pointer flex-col items-center justify-center rounded-lg border-2 border-dashed border-gray-300 p-8 transition-colors hover:border-blue-400"72 onDragOver={handleDragOver}73 onDrop={handleDrop}74 >75{preview ? (76<img77 src={preview}78 alt="Preview"79 className="mb-4 h-32 w-32 rounded-full object-cover"80 />81) : (82<>83<svg className="mb-3 h-12 w-12 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 48 48">84<path d="M28 8H12a4 4 0 00-4 4v20m32-12v8m0 0v8a4 4 0 01-4 4H12a4 4 0 01-4-4v-4m32-4l-3.172-3.172a4 4 0 00-5.656 0L28 28M8 32l9.172-9.172a4 4 0 015.656 0L28 28m0 0l4 4m4-24h8m-4-4v8m-12 4h.02" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" />85</svg>86<p className="text-sm text-gray-600">Drag image here or click to browse</p>87<p className="mt-1 text-xs text-gray-400">JPEG, PNG, WebP, GIF up to 5MB</p>88</>89)}90</div>91 92 <input93 type="file"94 accept="image/jpeg,image/png,image/webp,image/gif"95 className="hidden"96 id="avatar-input"97 onChange={handleFileSelect}98 />99 <label100 htmlFor="avatar-input"101 className="inline-block cursor-pointer rounded bg-gray-100 px-4 py-2 text-sm text-gray-700 hover:bg-gray-200"102 >103 Browse files104 </label>105 106 {error && <p className="text-sm text-red-600">{error}</p>}107 108 {preview && progress === 0 && (109 <button110 onClick={handleUpload}111 className="rounded bg-blue-600 px-6 py-2 text-sm text-white hover:bg-blue-700"112 >113 Upload Avatar114 </button>115 )}116 117 {progress > 0 && progress < 100 && (118 <div className="h-2 w-full rounded-full bg-gray-200">119 <div120 className="h-2 rounded-full bg-blue-600 transition-all"121 style={{ width: `${progress}%` }}122 />123 </div>124 )}125 126 {progress === 100 && (127 <p className="text-sm text-green-600">Upload complete!</p>128 )}129 </div>130 131)132}This component combines drag-and-drop, file validation, image preview, and XHR upload with a progress bar into a complete avatar-upload UI. Key behaviors: validates file type (JPEG/PNG/WebP/GIF) and size (max 5MB) on drop and on file-picker selection; shows an image preview via URL.createObjectURL; uploads with an XMLHttpRequest and renders a real-time progress bar; displays error messages for invalid files or failed uploads.