Create a Modern File Upload UI with Next.js and Dropzone

Today, I’ll walk you through building a slick, drag-and-drop file upload interface using Next.jsDropzone, 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.

Upload page
Drag and Drop file

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>
Decoration with Tailwind

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.

Upload success
Upload fail

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.

Upload multiple files

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 }
  );
}
Upload multiple files

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.jsDropzone, 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.

Next.js File Upload UI: 5 Implementation Steps
1. Setup & Dependencies
Action: Initialize Next.js project and install necessary packages.
Key Tools: Next.js (Framework), react-dropzone (Drag-and-drop logic), @heroui/react (UI components).
2. Create FileUpload Component
Action: Implement the core UI logic and state management.
Elements: useDropzone hook, Card (Container), Progress (Feedback), useState/useCallback (React logic).
3. Embed on App Page
Action: Place the `FileUpload` component within the main application page.
Best Practice: Center the component and ensure it occupies the main content area for good visibility.
4. Enhance Visual Design
Action: Apply transitions and state-based styling for better user feedback.
UX Focus: Highlight drag-over state (`isDragActive`), display file name, and show upload progress visually.
5. Implement Real File Upload Logic
Action: Replace simulation with actual server communication.
Backend: Next.js API Route (for serverless handling). Frontend: Use XMLHttpRequest to manage `onprogress` events for accurate progress bar updates.
Core Libraries: Next.js, react-dropzone, and HeroUI components.

This article was originally published on Medium.

Leave a Comment

Your email address will not be published. Required fields are marked *