Today, I’ll walk you through building a slick, drag-and-drop file upload interface using Next.js, Dropzone, and HeroUI. By the end, you’ll have a responsive, interactive, and stylish upload system that feels like it belongs in a premium web app.
Why This Stack?
Before we get our hands dirty, let’s talk tools.
- Next.js gives us fast, server-rendered React apps.
- Dropzone makes drag-and-drop file uploading painless.
- HeroUI offers modern UI components like cards, buttons, and progress bars.
When we integrate these, we get a polished user experience without reinventing the wheel.
Setting up the Next.js Project

npm install react-dropzone
Step 2: Creating the Upload Component
We’ll build a FileUpload.tsx component.
This will house the Dropzone logic, HeroUI’s Card for layout, a Button for manual selection, and a Progress bar for visual feedback.
components/FileUpload.tsx
import React, { useState, useCallback } from "react";
import { useDropzone } from "react-dropzone";
import { Card, Button, Progress } from "@heroui/react";
export default function FileUpload() {
const [files, setFiles] = useState<File[]>([]);
const [uploadProgress, setUploadProgress] = useState(0);
const onDrop = useCallback((acceptedFiles: File[]) => {
setFiles(acceptedFiles);
simulateUpload(acceptedFiles);
}, []);
const { getRootProps, getInputProps, isDragActive } = useDropzone({ onDrop });
const simulateUpload = (files: File[]) => {
let progress = 0;
const interval = setInterval(() => {
progress += 10;
setUploadProgress(progress);
if (progress >= 100) clearInterval(interval);
}, 300);
};
return (
<Card className="p-6 max-w-md mx-auto">
<div
{...getRootProps()}
className={`border-2 border-dashed rounded-md p-6 text-center cursor-pointer transition ${
isDragActive ? "bg-blue-50" : "bg-white"
}`}
>
<input {...getInputProps()} />
{isDragActive ? (
<p>Drop your files here...</p>
) : (
<p>Drag & drop files here, or click to select</p>
)}
</div>
<Button
color="primary"
className="mt-4"
onClick={() => document.querySelector<HTMLInputElement>("input")?.click()}
>
Select Files
</Button>
{files.length > 0 && (
<div className="mt-4">
<p className="font-semibold">Uploading: {files[0].name}</p>
<Progress value={uploadProgress} className="mt-2" />
</div>
)}
</Card>
);
}This code covers the basics:
- useDropzone handles the drag-and-drop magic.
- HeroUI Card wraps our upload zone in a sleek container.
- Button triggers manual file selection.
- Progress bar gives users feedback on uploading.
Step 3: Adding the Component to a Page
Let’s make it visible in our app.
Open app/upload/page.tsx and replace the default content:
import FileUpload from "@/components/FileUpload";
export default function Home() {
return (
<main className="min-h-screen flex items-center justify-center bg-gray-50">
<FileUpload />
</main>
);
}Run the development server:
>npm run dev
You’ll see your drop area ready to accept files.


Step 4: Styling the Experience
Good UI is more than just functional. Let’s add a few style tweaks.
Add Tailwind CSS for quick utility-based styling:
<Card className="p-6 max-w-md mx-auto shadow-lg rounded-lg">
<div
{...getRootProps()}
className={`
border-2 border-dashed rounded-md p-8 text-center
cursor-pointer transition-all duration-300 ease-in-out
${isDragActive ? "bg-blue-50 border-blue-400 text-blue-700" : "bg-white border-gray-300 text-gray-600"}
hover:border-blue-400 hover:text-blue-700 hover:bg-blue-50
`}
>
<input {...getInputProps()} />
{isDragActive ? (
<p className="text-lg font-medium">✨ Drop your files here...</p>
) : (
<p className="text-lg">
<span className="font-semibold">Drag & drop files here</span>, or
click to select
</p>
)}
</div>
{files.length > 0 && (
<div className="mt-6 p-4 bg-gray-50 rounded-md border border-gray-200">
<p className="font-semibold text-gray-800 mb-2">
Uploading: <span className="text-blue-600">{files[0].name}</span>
</p>
<Progress
value={uploadProgress}
className="h-2 bg-blue-500 rounded-full"
/>
<p className="text-sm text-gray-500 mt-2 text-right">
{uploadProgress}% Complete
</p>
</div>
)}
</Card>
Step 5: Making the Upload Real
Our simulateUpload function fakes progress.
Let’s make it real by sending files to an API route.
>npm install @type/formidable
1. Create a Next.js API route app/api/upload/route.ts:
// app/api/upload/route.ts
import { NextRequest, NextResponse } from "next/server";
import { writeFile } from 'fs/promises'; // For writing files asynchronously
import path from 'path'; // For path manipulation
export async function POST(req: NextRequest) {
if (!req.body) {
return NextResponse.json({ error: "No request body found" }, { status: 400 });
}
const contentType = req.headers.get('content-type');
if (!contentType || !contentType.startsWith('multipart/form-data')) {
return NextResponse.json({ error: "Invalid content type. Expected multipart/form-data" }, { status: 400 });
}
try {
const formData = await req.formData();
const file = formData.get('file') as File | null;
if (!file) {
return NextResponse.json({ error: "No file provided" }, { status: 400 });
}
const buffer = Buffer.from(await file.arrayBuffer());
const uploadDir = path.join(process.cwd(), 'public', 'uploads');
const filename = file.name;
const filePath = path.join(uploadDir, filename);
// Write the file to disk
await writeFile(filePath, buffer as unknown as Uint8Array);
return NextResponse.json({
success: true,
fileName: filename,
filePath: `/uploads/${filename}`, // Public URL path
fileSize: file.size,
fileType: file.type,
}, { status: 200 });
} catch (error) {
console.error("File upload error:", error);
return NextResponse.json({ error: "File upload failed" }, { status: 500 });
}
}
export async function GET() {
return NextResponse.json({ message: "GET method not allowed for this route." }, { status: 405 });
}2. Adjust the upload handler in FileUpload.tsx:
"use client";
import React, { useState, useCallback } from "react";
import { useDropzone } from "react-dropzone";
import { Card, Button, Progress, CardHeader, CardBody } from "@heroui/react";
interface UploadedFileDetails {
success: boolean;
fileName?: string;
filePath?: string;
fileSize?: number;
fileType?: string;
error?: string; // Add an error property for failed uploads
}
export default function FileUpload() {
const [files, setFiles] = useState<File[]>([]);
const [uploadProgress, setUploadProgress] = useState(0);
const [uploadedFileDetails, setUploadedFileDetails] = useState<UploadedFileDetails | null>(null);
const [uploadError, setUploadError] = useState<string | null>(null);
const onDrop = useCallback(async (acceptedFiles: File[]) => {
setFiles(acceptedFiles);
const formData = new FormData();
formData.append("file", acceptedFiles[0]);
console.log('formData')
const xhr = new XMLHttpRequest();
xhr.open("POST", "/api/upload", true);
xhr.upload.onprogress = (event) => {
if (event.lengthComputable) {
const progress = Math.round((event.loaded * 100) / event.total);
setUploadProgress(progress);
}
};
xhr.onload = () => {
if (xhr.status === 200) {
try {
const responseData: UploadedFileDetails = JSON.parse(xhr.responseText);
console.log("Upload complete:", responseData);
setUploadedFileDetails(responseData);
if (!responseData.success) {
setUploadError(responseData.error || "Upload failed with an unknown error.");
}
} catch (e) {
console.error("Failed to parse JSON response:", e);
setUploadError("Upload complete, but failed to process server response.");
}
} else {
// Handle non-200 responses (e.g., 4xx, 5xx errors from your API)
console.error(`Upload failed with status ${xhr.status}:`, xhr.responseText);
try {
const errorResponse: { error?: string } = JSON.parse(xhr.responseText);
setUploadError(errorResponse.error || `Upload failed with status ${xhr.status}.`);
} catch (e) {
setUploadError(`Upload failed with status ${xhr.status}. Please try again.`);
}
}
};
xhr.send(formData);
}, []);
const { getRootProps, getInputProps, isDragActive } = useDropzone({ onDrop });
return (
<Card className="p-6 max-w-md mx-auto shadow-lg rounded-lg">
<div
{...getRootProps()}
className={`
border-2 border-dashed rounded-md p-8 text-center
cursor-pointer transition-all duration-300 ease-in-out
${isDragActive ? "bg-blue-50 border-blue-400 text-blue-700" : "bg-white border-gray-300 text-gray-600"}
hover:border-blue-400 hover:text-blue-700 hover:bg-blue-50
`}
>
<input {...getInputProps()} />
{isDragActive ? (
<p className="text-lg font-medium">✨ Drop your files here...</p>
) : (
<p className="text-lg">
<span className="font-semibold">Drag & drop files here</span>, or
click to select
</p>
)}
</div>
{files.length > 0 && (
<div className="mt-6 p-4 bg-gray-50 rounded-md border border-gray-200">
<p className="font-semibold text-gray-800 mb-2">
Uploading: <span className="text-blue-600">{files[0].name}</span>
</p>
<Progress
value={uploadProgress}
className="h-2 bg-blue-500 rounded-full"
/>
<p className="text-sm text-gray-500 mt-2 text-right">
{uploadProgress}% Complete
</p>
</div>
)}
{uploadedFileDetails && (
<Card className="mt-6 p-4 bg-white shadow-md border border-gray-200">
<CardHeader className="text-lg font-semibold text-gray-800">
{uploadedFileDetails.success ? 'File Uploaded Successfully!' : 'Upload Failed'}
</CardHeader>
<CardBody className="text-gray-700">
{uploadedFileDetails.success ? (
<>
<p><strong>File Name:</strong> {uploadedFileDetails.fileName}</p>
<p><strong>File Size:</strong> {uploadedFileDetails.fileSize} bytes</p>
<p><strong>File Type:</strong> {uploadedFileDetails.fileType}</p>
</>
) : (
<p className="text-red-600 font-medium">Error: {uploadedFileDetails.error || "An unknown error occurred."}</p>
)}
</CardBody>
</Card>
)}
{uploadError && !uploadedFileDetails && ( // Display a general error if no specific details are available
<Card className="mt-6 p-4 bg-red-50 border border-red-200 shadow-md">
<CardHeader className="text-lg font-semibold text-red-700">Upload Error</CardHeader>
<CardBody className="text-red-600">
<p>{uploadError}</p>
</CardBody>
</Card>
)}
</Card>
);
}Now your upload progress reflects the real transfer.


Step 6: Handling Multiple Files
To support multiple uploads:
- Change the Dropzone config:
const { getRootProps, getInputProps, isDragActive } = useDropzone({
onDrop,
multiple: true,
});Loop through files when rendering:
{files.map((file, index) => (
<div key={index} className="mt-2">
<p>{file.name}</p>
<Progress value={uploadProgress} />
</div>
))}This way, you handle several uploads at once.

Modify FileUpload
"use client";
import React, { useState, useCallback } from "react";
import { useDropzone } from "react-dropzone";
import { Card, Button, Progress, CardHeader, CardBody } from "@heroui/react";
interface UploadedFileDetails {
success: boolean;
fileName?: string;
filePath?: string;
fileSize?: number;
fileType?: string;
error?: string; // Add an error property for failed uploads
}
export default function FileUpload() {
const [files, setFiles] = useState<File[]>([]);
const [uploadProgress, setUploadProgress] = useState(0);
const [uploadedFileDetails, setUploadedFileDetails] = useState<
UploadedFileDetails[] | null
>(null);
const [uploadError, setUploadError] = useState<string | null>(null);
const onDrop = useCallback(async (acceptedFiles: File[]) => {
setFiles(acceptedFiles);
const formData = new FormData();
acceptedFiles.forEach((file) => {
formData.append("files", file); // The key "files" is repeated for each file
});
console.log("formData");
const xhr = new XMLHttpRequest();
xhr.open("POST", "/api/upload", true);
xhr.upload.onprogress = (event) => {
if (event.lengthComputable) {
const progress = Math.round((event.loaded * 100) / event.total);
setUploadProgress(progress);
}
};
xhr.onload = () => {
if (xhr.status === 200) {
try {
const responseData = JSON.parse(xhr.responseText);
const uploadFile: UploadedFileDetails[] = responseData.uploadedFiles;
setUploadedFileDetails(uploadFile);
} catch (e) {
console.error("Failed to parse JSON response:", e);
setUploadError(
"Upload complete, but failed to process server response."
);
}
} else {
// Handle non-200 responses (e.g., 4xx, 5xx errors from your API)
console.error(
`Upload failed with status ${xhr.status}:`,
xhr.responseText
);
try {
const errorResponse: { error?: string } = JSON.parse(
xhr.responseText
);
setUploadError(
errorResponse.error || `Upload failed with status ${xhr.status}.`
);
} catch (e) {
setUploadError(
`Upload failed with status ${xhr.status}. Please try again.`
);
}
}
};
xhr.send(formData);
}, []);
const { getRootProps, getInputProps, isDragActive } = useDropzone({
onDrop,
multiple: true,
});
return (
<Card className="p-6 max-w-md mx-auto shadow-lg rounded-lg">
<div
{...getRootProps()}
className={`
border-2 border-dashed rounded-md p-8 text-center
cursor-pointer transition-all duration-300 ease-in-out
${isDragActive ? "bg-blue-50 border-blue-400 text-blue-700" : "bg-white border-gray-300 text-gray-600"}
hover:border-blue-400 hover:text-blue-700 hover:bg-blue-50
`}
>
<input {...getInputProps()} />
{isDragActive ? (
<p className="text-lg font-medium">✨ Drop your files here...</p>
) : (
<p className="text-lg">
<span className="font-semibold">Drag & drop files here</span>, or
click to select
</p>
)}
</div>
{files.map((file, index) => (
<div key={index} className="mt-2">
<p>{file.name}</p>
<Progress value={uploadProgress} />
</div>
))}
{uploadedFileDetails && uploadedFileDetails.length > 0 && (
<div className="mt-6 space-y-4">
{" "}
{/* Add vertical spacing between multiple cards */}
<h3 className="text-xl font-bold text-gray-800">Upload Results:</h3>
{uploadedFileDetails.map((file, index) => (
<Card
key={file.fileName || index} // Use file.fileName as key if unique, otherwise index
className={`p-4 shadow-md border ${
file.success
? "bg-green-50 border-green-200"
: "bg-red-50 border-red-200"
}`}
>
<CardHeader
className={`text-lg font-semibold ${
file.success ? "text-green-700" : "text-red-700"
}`}
>
{file.success
? `Uploaded: ${file.fileName}`
: `Failed: ${file.fileName || "Unknown File"}`}
</CardHeader>
<CardBody className="text-gray-700">
{file.success ? (
<>
<p>
<strong>File Name:</strong> {file.fileName}
</p>
<p>
<strong>File Size:</strong> {file.fileSize} bytes
</p>
<p>
<strong>File Type:</strong> {file.fileType}
</p>
{/* Optional: Display image preview if it's an image */}
{file.filePath && file.fileType?.startsWith("image/") && (
<div className="mt-4">
<p className="font-semibold mb-2">Preview:</p>
<img
src={file.filePath}
alt={file.fileName || "Uploaded image"}
className="max-w-full h-auto rounded-md shadow-sm border border-gray-200"
/>
</div>
)}
</>
) : (
<p className="text-red-600 font-medium">
Error: {file.error || "An unknown error occurred."}
</p>
)}
</CardBody>
</Card>
))}
</div>
)}
{uploadError &&
!uploadedFileDetails && ( // Display a general error if no specific details are available
<Card className="mt-6 p-4 bg-red-50 border border-red-200 shadow-md">
<CardHeader className="text-lg font-semibold text-red-700">
Upload Error
</CardHeader>
<CardBody className="text-red-600">
<p>{uploadError}</p>
</CardBody>
</Card>
)}
</Card>
);
}Modify API
// app/api/upload/route.ts
import { NextRequest, NextResponse } from "next/server";
import { writeFile } from "fs/promises"; // For writing files asynchronously
import path from "path"; // For path manipulation
interface UploadedFileDetails {
success: boolean;
fileName: string;
filePath: string;
fileSize: number;
fileType: string;
error?: string; // Optional error field for individual file failures if applicable
}
export async function POST(req: NextRequest) {
if (!req.body) {
return NextResponse.json(
{ error: "No request body found" },
{ status: 400 }
);
}
const contentType = req.headers.get("content-type");
if (!contentType || !contentType.startsWith("multipart/form-data")) {
return NextResponse.json(
{ error: "Invalid content type. Expected multipart/form-data" },
{ status: 400 }
);
}
try {
const uploadedFilesInfo: UploadedFileDetails[] = [];
const formData = await req.formData();
const filesArray = formData.getAll("files") as File[];
for (const file of filesArray) {
if (!file) {
return NextResponse.json(
{ error: "No file provided" },
{ status: 400 }
);
}
const buffer = Buffer.from(await file.arrayBuffer());
const uploadDir = path.join(process.cwd(), "public", "uploads");
const filename = file.name;
const filePath = path.join(uploadDir, filename);
// Write the file to disk
await writeFile(filePath, buffer as unknown as Uint8Array);
uploadedFilesInfo.push({
success: true,
fileName: filename,
filePath: `/uploads/${filename}`, // Public URL path
fileSize: file.size,
fileType: file.type,
});
}
return NextResponse.json(
{
success: true, // General success for the entire operation
uploadedFiles: uploadedFilesInfo,
message: "Files processed successfully",
},
{ status: 200 }
);
} catch (error) {
console.error("File upload error:", error);
return NextResponse.json({ error: "File upload failed" }, { status: 500 });
}
}
export async function GET() {
return NextResponse.json(
{ message: "GET method not allowed for this route." },
{ status: 405 }
);
}
Step 7: Deployment Tips
When deploying:
- Ensure the uploads folder exists on the server.
- For serverless platforms, consider cloud storage (AWS S3, Supabase Storage, etc.).
- Secure your upload endpoint to prevent abuse.
Finally
With Next.js, Dropzone, and HeroUI, we can build a modern, responsive file upload system without fighting the UI.
You now have:
- A polished drag-and-drop zone.
- Real-time progress updates.
- A scalable architecture for real uploads.
Play around with animations, error handling, and cloud integrations to take it further.
This article was originally published on Medium.



