UploadThing + Next.js App Router: Building a Complete File Upload System with Drag-and-Drop

File uploads solved for Next.js. UploadThing is a modern file upload service built specifically for TypeScript applications. In this tutorial, you will build a complete upload system with drag-and-drop, image previews, progress bars, and server-side validation — all type-safe from client to server.
What You Will Learn
By the end of this tutorial, you will:
- Set up UploadThing in a Next.js 15 App Router project
- Create type-safe file routers with server-side validation
- Build a drag-and-drop upload zone with visual feedback
- Show real-time upload progress with percentage indicators
- Implement image previews before and after upload
- Handle file size limits, type restrictions, and custom validation
- Build a file gallery that displays and manages uploaded files
- Delete files programmatically through the UploadThing API
Prerequisites
Before starting, ensure you have:
- Node.js 20+ installed (
node --version) - Next.js experience (App Router, Server Components, Server Actions)
- TypeScript basics (types, generics)
- An UploadThing account — sign up free at uploadthing.com
- A code editor — VS Code or Cursor recommended
Why UploadThing?
Handling file uploads in web applications has historically been painful. You either cobble together S3 presigned URLs with custom middleware, or rely on heavy libraries that fight your framework. UploadThing takes a different approach:
| Feature | UploadThing | Manual S3 | Multer + Express |
|---|---|---|---|
| Type safety | Full TypeScript | Manual types | None |
| Framework integration | Native Next.js | Custom setup | Express only |
| Presigned URLs | Automatic | Manual IAM setup | N/A |
| File validation | Declarative | Custom middleware | Custom middleware |
| React components | Built-in | Build your own | Build your own |
| Progress tracking | Built-in | Custom WebSocket | Custom |
The free tier includes 2 GB of storage and 2 GB of monthly bandwidth — more than enough for development and small projects.
Step 1: Create a Next.js Project
Start with a fresh Next.js 15 project:
npx create-next-app@latest upload-demo --typescript --tailwind --app --src-dir
cd upload-demoSelect the default options when prompted. This gives you a project with TypeScript, Tailwind CSS, and the App Router.
Step 2: Install UploadThing
Install the core package and the React integration:
npm install uploadthing @uploadthing/reactNext, create a .env.local file with your UploadThing credentials. You can find these in the UploadThing dashboard after creating a new app:
UPLOADTHING_TOKEN=your_token_hereThe UPLOADTHING_TOKEN is automatically picked up by the library — no additional configuration needed.
Step 3: Define the File Router
The file router is the core of UploadThing. It defines what types of files your application accepts, how large they can be, and what happens after upload.
Create src/app/api/uploadthing/core.ts:
import { createUploadthing, type FileRouter } from "uploadthing/next";
import { UploadThingError } from "uploadthing/server";
const f = createUploadthing();
export const ourFileRouter = {
imageUploader: f({
image: {
maxFileSize: "4MB",
maxFileCount: 4,
},
})
.middleware(async ({ req }) => {
// Run any server-side logic before upload
// For example, check authentication
const user = { id: "user_123" }; // Replace with real auth
if (!user) throw new UploadThingError("Unauthorized");
return { userId: user.id };
})
.onUploadComplete(async ({ metadata, file }) => {
console.log("Upload complete for user:", metadata.userId);
console.log("File URL:", file.ufsUrl);
// Return data to the client
return { uploadedBy: metadata.userId, url: file.ufsUrl };
}),
documentUploader: f({
pdf: { maxFileSize: "16MB", maxFileCount: 1 },
"application/msword": { maxFileSize: "16MB", maxFileCount: 1 },
})
.middleware(async ({ req }) => {
return { uploadedAt: new Date().toISOString() };
})
.onUploadComplete(async ({ metadata, file }) => {
return { url: file.ufsUrl, uploadedAt: metadata.uploadedAt };
}),
} satisfies FileRouter;
export type OurFileRouter = typeof ourFileRouter;Key concepts:
createUploadthing()returns a builder functionffor defining routes- File type config specifies allowed MIME types, max size, and max count
middleware()runs on the server before upload starts — perfect for authenticationonUploadComplete()fires after the file is stored — save metadata to your database here- The
satisfies FileRouterensures type safety across the entire chain
Step 4: Create the API Route
Create the Next.js route handler at src/app/api/uploadthing/route.ts:
import { createRouteHandler } from "uploadthing/next";
import { ourFileRouter } from "./core";
export const { GET, POST } = createRouteHandler({
router: ourFileRouter,
});This two-line file creates the GET and POST handlers that UploadThing needs to negotiate uploads with the client.
Step 5: Generate the React Helpers
Create a utility file that generates type-safe React hooks and components from your file router.
Create src/utils/uploadthing.ts:
import {
generateUploadButton,
generateUploadDropzone,
generateReactHelpers,
} from "@uploadthing/react";
import type { OurFileRouter } from "@/app/api/uploadthing/core";
export const UploadButton = generateUploadButton<OurFileRouter>();
export const UploadDropzone = generateUploadDropzone<OurFileRouter>();
export const { useUploadThing } = generateReactHelpers<OurFileRouter>();These generated components are fully typed — your IDE will autocomplete the endpoint names and know exactly what metadata each route returns.
Step 6: Add UploadThing Styles
Import the default UploadThing styles in your src/app/layout.tsx:
import "@uploadthing/react/styles.css";Add this line alongside your existing CSS imports. The styles provide a polished default look for the upload button and dropzone components.
Step 7: Build the Basic Upload Button
Let us start with the simplest integration — a styled upload button.
Create src/components/BasicUpload.tsx:
"use client";
import { UploadButton } from "@/utils/uploadthing";
import { useState } from "react";
interface UploadedFile {
url: string;
name: string;
}
export default function BasicUpload() {
const [files, setFiles] = useState<UploadedFile[]>([]);
return (
<div className="flex flex-col items-center gap-4">
<h2 className="text-xl font-bold">Upload an Image</h2>
<UploadButton
endpoint="imageUploader"
onClientUploadComplete={(res) => {
if (res) {
const uploaded = res.map((file) => ({
url: file.ufsUrl,
name: file.name,
}));
setFiles((prev) => [...prev, ...uploaded]);
}
}}
onUploadError={(error: Error) => {
alert(`Upload failed: ${error.message}`);
}}
/>
{files.length > 0 && (
<div className="grid grid-cols-2 gap-4 mt-4">
{files.map((file, i) => (
<div key={i} className="relative">
<img
src={file.url}
alt={file.name}
className="w-48 h-48 object-cover rounded-lg"
/>
<p className="text-sm text-center mt-1 truncate w-48">
{file.name}
</p>
</div>
))}
</div>
)}
</div>
);
}The endpoint prop is fully typed — try typing a wrong endpoint name and TypeScript will catch it immediately.
Step 8: Build the Drag-and-Drop Upload Zone
The dropzone provides a larger target area with drag-and-drop support.
Create src/components/DropzoneUpload.tsx:
"use client";
import { UploadDropzone } from "@/utils/uploadthing";
import { useState } from "react";
interface UploadedFile {
url: string;
name: string;
size: number;
}
export default function DropzoneUpload() {
const [files, setFiles] = useState<UploadedFile[]>([]);
return (
<div className="w-full max-w-xl mx-auto">
<h2 className="text-xl font-bold mb-4">Drop Your Images Here</h2>
<UploadDropzone
endpoint="imageUploader"
onClientUploadComplete={(res) => {
if (res) {
const uploaded = res.map((file) => ({
url: file.ufsUrl,
name: file.name,
size: file.size,
}));
setFiles((prev) => [...prev, ...uploaded]);
}
}}
onUploadError={(error: Error) => {
alert(`Upload failed: ${error.message}`);
}}
config={{ mode: "auto" }}
/>
{files.length > 0 && (
<div className="mt-6 space-y-2">
<h3 className="font-semibold">Uploaded Files:</h3>
{files.map((file, i) => (
<div
key={i}
className="flex items-center gap-3 p-3 bg-gray-50 rounded-lg"
>
<img
src={file.url}
alt={file.name}
className="w-12 h-12 object-cover rounded"
/>
<div className="flex-1 min-w-0">
<p className="text-sm font-medium truncate">{file.name}</p>
<p className="text-xs text-gray-500">
{(file.size / 1024).toFixed(1)} KB
</p>
</div>
</div>
))}
</div>
)}
</div>
);
}Setting config mode to "auto" means files upload immediately when dropped — no extra click needed. Remove this prop to require a manual "Upload" button click after dropping.
Step 9: Build a Custom Upload with Progress Tracking
For full control over the UI, use the useUploadThing hook directly.
Create src/components/CustomUpload.tsx:
"use client";
import { useUploadThing } from "@/utils/uploadthing";
import { useCallback, useState } from "react";
import { useDropzone } from "react-dropzone";
interface FileWithPreview extends File {
preview: string;
}
export default function CustomUpload() {
const [files, setFiles] = useState<FileWithPreview[]>([]);
const [uploadProgress, setUploadProgress] = useState<number>(0);
const [uploadedUrls, setUploadedUrls] = useState<string[]>([]);
const [isUploading, setIsUploading] = useState(false);
const { startUpload } = useUploadThing("imageUploader", {
onUploadProgress: (progress) => {
setUploadProgress(progress);
},
onClientUploadComplete: (res) => {
if (res) {
setUploadedUrls(res.map((file) => file.ufsUrl));
}
setIsUploading(false);
setUploadProgress(0);
setFiles([]);
},
onUploadError: (error) => {
alert(`Error: ${error.message}`);
setIsUploading(false);
setUploadProgress(0);
},
});
const onDrop = useCallback((acceptedFiles: File[]) => {
const withPreviews = acceptedFiles.map((file) =>
Object.assign(file, {
preview: URL.createObjectURL(file),
})
);
setFiles(withPreviews);
}, []);
const { getRootProps, getInputProps, isDragActive } = useDropzone({
onDrop,
accept: { "image/*": [".png", ".jpg", ".jpeg", ".webp"] },
maxFiles: 4,
maxSize: 4 * 1024 * 1024,
});
const handleUpload = async () => {
if (files.length === 0) return;
setIsUploading(true);
await startUpload(files);
};
return (
<div className="w-full max-w-xl mx-auto space-y-4">
<h2 className="text-xl font-bold">Custom Upload with Preview</h2>
<div
{...getRootProps()}
className={`border-2 border-dashed rounded-xl p-8 text-center cursor-pointer transition-colors ${
isDragActive
? "border-blue-500 bg-blue-50"
: "border-gray-300 hover:border-gray-400"
}`}
>
<input {...getInputProps()} />
{isDragActive ? (
<p className="text-blue-600">Drop the files here...</p>
) : (
<div>
<p className="text-gray-600">
Drag and drop images here, or click to select
</p>
<p className="text-sm text-gray-400 mt-1">
PNG, JPG, WebP — up to 4 MB each, max 4 files
</p>
</div>
)}
</div>
{files.length > 0 && (
<div className="grid grid-cols-4 gap-2">
{files.map((file, i) => (
<div key={i} className="relative aspect-square">
<img
src={file.preview}
alt={file.name}
className="w-full h-full object-cover rounded-lg"
/>
</div>
))}
</div>
)}
{isUploading && (
<div className="w-full bg-gray-200 rounded-full h-3">
<div
className="bg-blue-600 h-3 rounded-full transition-all duration-300"
style={{ width: `${uploadProgress}%` }}
/>
<p className="text-sm text-center mt-1">{uploadProgress}%</p>
</div>
)}
<button
onClick={handleUpload}
disabled={files.length === 0 || isUploading}
className="w-full py-2 px-4 bg-blue-600 text-white rounded-lg hover:bg-blue-700 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
>
{isUploading ? "Uploading..." : `Upload ${files.length} file(s)`}
</button>
{uploadedUrls.length > 0 && (
<div className="p-4 bg-green-50 rounded-lg">
<p className="font-semibold text-green-800">Upload complete!</p>
{uploadedUrls.map((url, i) => (
<a
key={i}
href={url}
target="_blank"
rel="noopener noreferrer"
className="block text-sm text-green-600 hover:underline truncate"
>
{url}
</a>
))}
</div>
)}
</div>
);
}You will need to install react-dropzone for this component:
npm install react-dropzoneThis custom implementation gives you:
- Image previews before upload using
URL.createObjectURL - A progress bar that fills as the upload proceeds
- Drag-and-drop with visual feedback (border color change)
- File type and size restrictions enforced on the client
- Full control over every element of the UI
Step 10: Server-Side File Validation
The middleware function in your file router is where serious validation happens. Here is an enhanced version with multiple checks:
import { createUploadthing, type FileRouter } from "uploadthing/next";
import { UploadThingError } from "uploadthing/server";
const f = createUploadthing();
async function authenticateUser(req: Request) {
// Replace with your actual auth logic
const token = req.headers.get("authorization");
if (!token) return null;
return { id: "user_123", plan: "pro" as const };
}
const PLAN_LIMITS = {
free: { maxSize: "2MB" as const, maxCount: 2 },
pro: { maxSize: "8MB" as const, maxCount: 10 },
};
export const ourFileRouter = {
imageUploader: f({
image: {
maxFileSize: "8MB",
maxFileCount: 10,
},
})
.middleware(async ({ req, files }) => {
const user = await authenticateUser(req);
if (!user) throw new UploadThingError("Unauthorized");
const limits = PLAN_LIMITS[user.plan];
// Validate file count against plan
if (files.length > limits.maxCount) {
throw new UploadThingError(
`Your plan allows up to ${limits.maxCount} files per upload`
);
}
return { userId: user.id, plan: user.plan };
})
.onUploadComplete(async ({ metadata, file }) => {
// Save to your database
// await db.insert(uploads).values({
// userId: metadata.userId,
// url: file.ufsUrl,
// name: file.name,
// size: file.size,
// });
return { url: file.ufsUrl };
}),
} satisfies FileRouter;
export type OurFileRouter = typeof ourFileRouter;The middleware runs entirely on the server — users cannot bypass it by modifying client code. This is where you enforce authentication, plan limits, rate limiting, and any business rules.
Step 11: Build a File Gallery with Delete
A complete upload system needs file management. Here is a gallery component that displays uploads and allows deletion.
First, create a Server Action for deletion in src/app/actions.ts:
"use server";
import { UTApi } from "uploadthing/server";
const utapi = new UTApi();
export async function deleteFile(fileKey: string) {
try {
await utapi.deleteFiles(fileKey);
return { success: true };
} catch (error) {
return { success: false, error: "Failed to delete file" };
}
}Now create src/components/FileGallery.tsx:
"use client";
import { useState } from "react";
import { deleteFile } from "@/app/actions";
interface GalleryFile {
key: string;
url: string;
name: string;
size: number;
}
export default function FileGallery({
initialFiles,
}: {
initialFiles: GalleryFile[];
}) {
const [files, setFiles] = useState<GalleryFile[]>(initialFiles);
const [deleting, setDeleting] = useState<string | null>(null);
const handleDelete = async (fileKey: string) => {
setDeleting(fileKey);
const result = await deleteFile(fileKey);
if (result.success) {
setFiles((prev) => prev.filter((f) => f.key !== fileKey));
} else {
alert("Failed to delete file");
}
setDeleting(null);
};
if (files.length === 0) {
return (
<div className="text-center py-12 text-gray-500">
<p>No files uploaded yet.</p>
</div>
);
}
return (
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-4">
{files.map((file) => (
<div
key={file.key}
className="group relative bg-white rounded-xl shadow-sm overflow-hidden"
>
<div className="aspect-square">
<img
src={file.url}
alt={file.name}
className="w-full h-full object-cover"
/>
</div>
<div className="p-2">
<p className="text-sm font-medium truncate">{file.name}</p>
<p className="text-xs text-gray-400">
{(file.size / 1024).toFixed(1)} KB
</p>
</div>
<button
onClick={() => handleDelete(file.key)}
disabled={deleting === file.key}
className="absolute top-2 right-2 opacity-0 group-hover:opacity-100 bg-red-500 text-white rounded-full w-8 h-8 flex items-center justify-center text-sm transition-opacity hover:bg-red-600 disabled:opacity-50"
>
{deleting === file.key ? "..." : "X"}
</button>
</div>
))}
</div>
);
}The UTApi class provides server-side methods for managing files: listing, deleting, renaming, and getting file URLs. Use it in Server Actions or API routes — never expose it to the client.
Step 12: Wire Everything Together
Update your main page to showcase all the upload components.
Replace src/app/page.tsx:
import BasicUpload from "@/components/BasicUpload";
import DropzoneUpload from "@/components/DropzoneUpload";
import CustomUpload from "@/components/CustomUpload";
export default function Home() {
return (
<main className="min-h-screen bg-gray-50 py-12 px-4">
<div className="max-w-4xl mx-auto space-y-16">
<div className="text-center">
<h1 className="text-3xl font-bold">UploadThing Demo</h1>
<p className="text-gray-600 mt-2">
Three ways to handle file uploads in Next.js
</p>
</div>
<section className="bg-white rounded-2xl p-8 shadow-sm">
<BasicUpload />
</section>
<section className="bg-white rounded-2xl p-8 shadow-sm">
<DropzoneUpload />
</section>
<section className="bg-white rounded-2xl p-8 shadow-sm">
<CustomUpload />
</section>
</div>
</main>
);
}Start the development server:
npm run devVisit http://localhost:3000 and test each upload method. Try dragging images, clicking to select files, and watching the progress bar fill.
Testing Your Implementation
Verify these scenarios work correctly:
- Single file upload — click the upload button and select one image
- Multiple file upload — select up to 4 images at once
- Drag and drop — drag images from your file manager onto the dropzone
- File size rejection — try uploading a file larger than 4 MB
- Wrong file type — try uploading a
.txtfile to the image uploader - Progress tracking — upload a larger image and watch the progress bar
- Preview before upload — drop files in the custom component and verify thumbnails
- Delete — hover over a gallery image and click the delete button
Troubleshooting
"UPLOADTHING_TOKEN is not set"
Make sure your .env.local file exists in the project root and contains the token. Restart the dev server after adding environment variables.
Uploads fail silently
Check the browser Network tab for requests to /api/uploadthing. Common issues:
- The API route file is in the wrong location (must be
app/api/uploadthing/route.ts) - The token is expired or belongs to a different app
- CORS issues when testing from a different domain
Files upload but URLs return 404
UploadThing URLs follow the format https://ufs.sh/f/.... If you see old-style utfs.io URLs, update your package to the latest version.
TypeScript errors on endpoint names
Run npm run dev at least once after changing your file router. The types are generated from the router definition — your IDE needs the dev server running to pick them up.
Production Deployment
When deploying to production, keep these points in mind:
- Environment variables — set
UPLOADTHING_TOKENin your hosting provider (Vercel, Railway, etc.) - Callback URL — UploadThing needs to reach your server for
onUploadComplete. On Vercel, this works automatically. For custom deployments, configure the callback URL in the UploadThing dashboard - File cleanup — implement a cron job or background task to delete orphaned uploads (files uploaded but never saved to your database)
- Rate limiting — add rate limiting in your middleware to prevent abuse
- Content moderation — for user-generated content, consider integrating image moderation APIs in
onUploadComplete
Next Steps
Now that you have a working upload system, consider these enhancements:
- Database persistence — save file metadata to your database in
onUploadCompleteusing Prisma or Drizzle - Authentication — integrate with NextAuth.js or Clerk to validate users in the middleware
- Image optimization — use Next.js
Imagecomponent with uploaded URLs for automatic optimization - Presigned URLs — for private files, generate time-limited access URLs with the
UTApi - Webhooks — set up UploadThing webhooks for asynchronous processing (virus scanning, thumbnail generation)
Conclusion
You built a complete file upload system using UploadThing and Next.js App Router. The implementation covers three approaches — from the zero-config UploadButton to a fully custom drag-and-drop interface with progress tracking and image previews.
UploadThing removes the infrastructure complexity of file uploads: no S3 buckets to configure, no IAM policies to debug, no presigned URL logic to write. You define what files you accept, who can upload them, and what happens after — the rest is handled for you.
The type safety from client to server means your IDE catches mistakes at development time, not in production. When you change a file route name, add a new endpoint, or modify the metadata shape, TypeScript guides you through every file that needs updating.
For further reading, check out the UploadThing documentation and explore the UTApi for advanced server-side file operations like listing files, getting usage stats, and generating presigned URLs for private content.
Discuss Your Project with Us
We're here to help with your web development needs. Schedule a call to discuss your project and how we can assist you.
Let's find the best solutions for your needs.
Related Articles

Authenticate Your Next.js 15 App with Auth.js v5: Email, OAuth, and Role-Based Access
Learn how to add production-ready authentication to your Next.js 15 application using Auth.js v5. This comprehensive guide covers Google OAuth, email/password credentials, protected routes, middleware, and role-based access control.

Build a Local AI Chatbot with Ollama and Next.js: Complete Guide
Build a private, fully local AI chatbot using Ollama and Next.js. This hands-on tutorial covers installation, streaming responses, model selection, and deploying a production-ready chat interface — all without sending data to the cloud.

Building a Content-Driven Website with Payload CMS 3 and Next.js
Learn how to build a full-featured content-driven website using Payload CMS 3, which runs natively inside Next.js App Router. This tutorial covers collections, rich text editing, media uploads, authentication, and deploying to production.