UploadThing is a type-safe file upload solution built for Next.js — createUploadthing() creates a router builder. File route definitions include middleware for auth and onUploadComplete for post-upload logic. UploadButton and UploadDropzone are drop-in React components that connect to your router. generateReactHelpers(router) creates typed useUploadThing hooks for custom upload UI. File validation is declarative: image({ maxFileSize: "4MB", maxFileCount: 4 }). UploadThing stores files in their CDN and returns a public URL. UTApi from uploadthing/server lists and deletes files programmatically. Middleware receives req and can throw UPLOADTHING_ERROR to reject uploads. Upload state includes isUploading, progress, and fileUrls. Claude Code generates UploadThing router definitions, middleware auth guards, custom drag-and-drop components, and file management patterns.
CLAUDE.md for UploadThing
## UploadThing Stack
- Version: uploadthing >= 6.12, @uploadthing/react >= 6.12
- Router: f({ image: { maxFileSize: "4MB" } }).middleware(async ({ req }) => { ... }).onUploadComplete(async ({ metadata, file }) => { ... })
- Handler: createRouteHandler({ router }) in app/api/uploadthing/route.ts
- Components: <UploadButton endpoint="imageUploader" onClientUploadComplete={(files) => {}} />
- Hook: const { startUpload, isUploading } = useUploadThing("imageUploader")
- UTApi: const utapi = new UTApi(); await utapi.deleteFiles(fileKey)
- Env: UPLOADTHING_SECRET in .env — get from uploadthing.com dashboard
Server Router
// lib/uploadthing/router.ts — file upload route definitions
import { createUploadthing, type FileRouter } from "uploadthing/next"
import { UploadThingError } from "uploadthing/server"
import { auth } from "@clerk/nextjs/server"
import { db } from "@/lib/db"
const f = createUploadthing()
// Custom middleware helper
async function requireUser() {
const { userId } = await auth()
if (!userId) throw new UploadThingError("Unauthorized")
return { userId }
}
export const uploadRouter = {
// Profile avatar — single image, small size
profileAvatar: f({ image: { maxFileSize: "2MB", maxFileCount: 1 } })
.middleware(async () => requireUser())
.onUploadComplete(async ({ metadata, file }) => {
await db.user.update({
where: { clerkId: metadata.userId },
data: { avatarUrl: file.url },
})
return { url: file.url }
}),
// Product images — multiple, larger
productImages: f({ image: { maxFileSize: "8MB", maxFileCount: 8 } })
.middleware(async ({ req }) => {
const { userId } = await requireUser()
const productId = req.headers.get("x-product-id")
if (!productId) throw new UploadThingError("Product ID required")
// Verify user owns this product
const product = await db.product.findFirst({
where: { id: productId, userId },
})
if (!product) throw new UploadThingError("Product not found")
return { userId, productId }
})
.onUploadComplete(async ({ metadata, file }) => {
await db.productImage.create({
data: {
productId: metadata.productId,
url: file.url,
key: file.key,
name: file.name,
size: file.size,
},
})
return { url: file.url, key: file.key }
}),
// Document upload — PDF/Word/Excel
documents: f({
pdf: { maxFileSize: "32MB" },
"application/msword": { maxFileSize: "16MB" },
"application/vnd.openxmlformats-officedocument.wordprocessingml.document": { maxFileSize: "16MB" },
})
.middleware(requireUser)
.onUploadComplete(async ({ metadata, file }) => {
const doc = await db.document.create({
data: {
userId: metadata.userId,
name: file.name,
url: file.url,
key: file.key,
size: file.size,
mimeType: file.type,
},
})
return { id: doc.id, url: file.url }
}),
// Video upload — for course content
courseVideo: f({ video: { maxFileSize: "512MB", maxFileCount: 1 } })
.middleware(async ({ req }) => {
const { userId } = await requireUser()
// Check user has permission to upload course content
const user = await db.user.findUnique({ where: { clerkId: userId } })
if (user?.role !== "instructor" && user?.role !== "admin") {
throw new UploadThingError("Instructor access required")
}
return { userId }
})
.onUploadComplete(async ({ metadata, file }) => {
// Trigger processing job
await videoQueue.add("process-video", { key: file.key, url: file.url, userId: metadata.userId })
return { url: file.url, key: file.key }
}),
} satisfies FileRouter
export type AppRouter = typeof uploadRouter
// app/api/uploadthing/route.ts — catch-all handler
import { createRouteHandler } from "uploadthing/next"
import { uploadRouter } from "@/lib/uploadthing/router"
export const { GET, POST } = createRouteHandler({ router: uploadRouter })
React Components
// components/upload/AvatarUploader.tsx — profile picture upload
"use client"
import { useState } from "react"
import { UploadButton } from "@uploadthing/react"
import type { AppRouter } from "@/lib/uploadthing/router"
import Image from "next/image"
interface AvatarUploaderProps {
currentAvatarUrl?: string | null
onUploadComplete?: (url: string) => void
}
export function AvatarUploader({ currentAvatarUrl, onUploadComplete }: AvatarUploaderProps) {
const [avatarUrl, setAvatarUrl] = useState(currentAvatarUrl ?? null)
return (
<div className="flex flex-col items-center gap-4">
<div className="relative size-24 rounded-full overflow-hidden bg-muted">
{avatarUrl ? (
<Image src={avatarUrl} alt="Avatar" fill className="object-cover" />
) : (
<div className="size-full flex items-center justify-center text-3xl text-muted-foreground">
👤
</div>
)}
</div>
<UploadButton<AppRouter, "profileAvatar">
endpoint="profileAvatar"
onClientUploadComplete={(res) => {
const url = res[0].serverData?.url ?? res[0].url
setAvatarUrl(url)
onUploadComplete?.(url)
}}
onUploadError={(error) => {
console.error("Upload error:", error.message)
}}
appearance={{
button: "btn-outline text-sm",
allowedContent: "text-xs text-muted-foreground",
}}
content={{
button: "Change photo",
allowedContent: "JPEG, PNG, WebP up to 2MB",
}}
/>
</div>
)
}
// components/upload/ProductImageDropzone.tsx — multi-image upload
"use client"
import { useState, useCallback } from "react"
import { useUploadThing } from "@uploadthing/react"
import type { AppRouter } from "@/lib/uploadthing/router"
interface ProductImageDropzoneProps {
productId: string
onUploadComplete: (urls: string[]) => void
}
export function ProductImageDropzone({ productId, onUploadComplete }: ProductImageDropzoneProps) {
const [files, setFiles] = useState<File[]>([])
const [previews, setPreviews] = useState<string[]>([])
const [progress, setProgress] = useState(0)
const { startUpload, isUploading } = useUploadThing<AppRouter>("productImages", {
headers: { "x-product-id": productId },
onUploadProgress: (p) => setProgress(p),
onClientUploadComplete: (res) => {
const urls = res.map(f => f.url)
onUploadComplete(urls)
setFiles([])
setPreviews([])
setProgress(0)
},
})
const handleDrop = useCallback((e: React.DragEvent) => {
e.preventDefault()
const dropped = Array.from(e.dataTransfer.files).filter(f => f.type.startsWith("image/"))
setFiles(prev => [...prev, ...dropped].slice(0, 8))
setPreviews(prev => [
...prev,
...dropped.map(f => URL.createObjectURL(f)),
].slice(0, 8))
}, [])
const removeFile = (index: number) => {
setFiles(prev => prev.filter((_, i) => i !== index))
URL.revokeObjectURL(previews[index])
setPreviews(prev => prev.filter((_, i) => i !== index))
}
return (
<div className="space-y-4">
<div
onDrop={handleDrop}
onDragOver={e => e.preventDefault()}
className="border-2 border-dashed border-muted-foreground/30 rounded-xl p-8 text-center hover:border-primary/50 transition-colors cursor-pointer"
>
<p className="text-muted-foreground">Drag images here or click to browse</p>
<p className="text-xs text-muted-foreground mt-1">Up to 8 images, 8MB each</p>
</div>
{previews.length > 0 && (
<div className="grid grid-cols-4 gap-2">
{previews.map((src, i) => (
<div key={i} className="relative aspect-square rounded-lg overflow-hidden group">
<img src={src} alt="" className="w-full h-full object-cover" />
<button
onClick={() => removeFile(i)}
className="absolute top-1 right-1 bg-black/50 text-white rounded-full size-5 text-xs opacity-0 group-hover:opacity-100 transition-opacity"
>
×
</button>
</div>
))}
</div>
)}
{isUploading && (
<div className="space-y-1">
<div className="flex justify-between text-xs text-muted-foreground">
<span>Uploading...</span>
<span>{progress}%</span>
</div>
<div className="h-1.5 bg-muted rounded-full overflow-hidden">
<div className="h-full bg-primary transition-all" style={{ width: `${progress}%` }} />
</div>
</div>
)}
<button
onClick={() => files.length && startUpload(files)}
disabled={files.length === 0 || isUploading}
className="btn-primary w-full"
>
{isUploading ? `Uploading (${progress}%)...` : `Upload ${files.length} image${files.length !== 1 ? "s" : ""}`}
</button>
</div>
)
}
For the Cloudinary Upload Widget alternative when a hosted upload widget with built-in image editing (crop, rotate, filter) and transformation CDN storage are preferred — Cloudinary’s widget provides a complete upload UX with no server code, though it’s tied to the Cloudinary ecosystem and pricing model, see the Cloudinary guide. For the AWS S3 pre-signed URL alternative when direct-to-S3 uploads with full control over storage, bucket policies, and zero per-file pricing beyond storage costs are preferred — generating pre-signed URLs and uploading from the browser directly avoids routing through your server, see the AWS S3 guide. The Claude Skills 360 bundle includes UploadThing skill sets covering routers, components, and file management. Start with the free tier to try file upload generation.