Abdessamad Ely Logo

Nextjs Multi File Uploader

Source code for the Nextjs Multi File Uploader project.

Abdessamad Ely
Abdessamad Ely
Software Engineer

route.ts

import mime from "mime";
import { join } from "path";
import { writeFile } from "fs/promises";

const UPLOAD_DIR = join(process.cwd(), "public/uploads");

export async function POST(request: Request) {
  const formData = await request.formData();
  const files = formData.getAll("files");
  const uploadFilePromises: Promise<string | false>[] = [];

  if (files.length === 0) {
    return Response.json({ uploadedFiles: [] });
  }

  for (const file of files) {
    if (!(file instanceof Blob)) {
      continue;
    }

    uploadFilePromises.push(uploadFile(file));
  }

  return Response.json({
    uploadedFiles: (await Promise.all(uploadFilePromises)).filter(
      (uploadedFile) => uploadedFile !== false
    ),
  });
}

async function uploadFile(file: Blob): Promise<string | false> {
  const buffer = Buffer.from(await file.arrayBuffer());
  const uniqueSuffix = `${Date.now()}-${Math.round(
    Math.random() * 1e9
  )}`;

  const filenameParts: string[] = [];
  if ("name" in file && typeof file.name === "string") {
    filenameParts.push(file.name.replace(/\.[^/.]+$/, ""));
  }
  filenameParts.push(
    `${uniqueSuffix}.${mime.getExtension(file.type)}`
  );

  try {
    const filename = filenameParts.join("-");
    await writeFile(`${UPLOAD_DIR}/${filename}`, buffer);
    return `/uploads/${filename}`;
  } catch (e) {
    console.error(
      `Error while trying to upload the file: ${filenameParts[0]}.\n`,
      e
    );

    return false;
  }
}

page.tsx

import { FileUploader } from "@/components";

export default function Page() {
  return (
    <div className="space-y-3">
      <h1 className="text-xl font-bold">File Uploader Form</h1>
      <FileUploader apiUrl="/api/upload" />
    </div>
  );
}

globals.css

@tailwind base;
@tailwind components;
@tailwind utilities;

layout.tsx

import type { Metadata } from "next";
import "./globals.css";

export const metadata: Metadata = {
  title:
    "Codersteps project for Building a File Uploader with Next.js App Router",
  description:
    "Codersteps project for Building a File Uploader with Next.js App Router",
};

export default function RootLayout({
  children,
}: Readonly<{
  children: React.ReactNode;
}>) {
  return (
    <html lang="en">
      <body className="h-screen">
        <main className="h-full flex items-center justify-center">
          <div className="w-full max-w-xl space-y-5">{children}</div>
        </main>
      </body>
    </html>
  );
}

page.tsx

import Link from "next/link";

export default function Home() {
  return (
    <div>
      <h1 className="text-2xl font-bold">
        Codersteps project for Building a File Uploader with Next.js App Router
      </h1>
      <div>
        <Link
          className="text-sm font-medium text-blue-500 hover:underline"
          href="/uploader"
        >
          Go to the Uploader page
        </Link>
      </div>
    </div>
  );
}

FileUploader.tsx

"use client";

import Image from "next/image";
import { ChangeEvent, useState } from "react";

export function FileUploader({ apiUrl }: { apiUrl: string }) {
  const [selectedFiles, setSelectedFiles] = useState<File[]>([]);
  const [uploadedFiles, setUploadedFiles] = useState<string[]>([]);

  async function uploadSelectedFiles() {
    if (apiUrl.trim().length === 0) {
      console.warn("Please provide a valid apiUrl.");
      return [];
    }

    if (selectedFiles.length === 0) {
      return [];
    }

    const formData = new FormData();

    for (const file of selectedFiles) {
      formData.append("files", file);
    }

    try {
      const res = await fetch(apiUrl, {
        method: "POST",
        body: formData,
      });

      if (!res.ok) {
        console.error("something went wrong, check your console.");
        return [];
      }

      const data: { uploadedFiles: string[] } = await res.json();
      return data.uploadedFiles;
    } catch (error) {
      console.error("something went wrong, check your server/client console.");
    }

    return [];
  }

  async function onChange(e: ChangeEvent<HTMLInputElement>) {
    const { files } = e.target;

    if (!files || files.length === 0) {
      console.warn("files list is empty");
      return;
    }

    setSelectedFiles(Array.from(files));
  }

  async function onUpload() {
    setUploadedFiles(await uploadSelectedFiles());
    console.log("All files were uploaded successfully.");

    setSelectedFiles([]);
  }

  function onClear() {
    setSelectedFiles([]);
    setUploadedFiles([]);
  }

  if (selectedFiles.length === 0 && uploadedFiles.length === 0) {
    return (
      <label className="border border-dashed border-[#666666] hover:border-black rounded bg-gray-100 hover:bg-gray-200 text-[#666666] hover:text-black transition-colors duration-300 h-48 flex items-center justify-center">
        <span className="text-sm font-medium">Select an Image</span>
        <input
          className="h-0 w-0"
          type="file"
          accept="image/*"
          multiple
          onChange={onChange}
        />
      </label>
    );
  }

  if (selectedFiles.length > 0 || uploadedFiles.length > 0) {
    return (
      <div className="border border-dashed border-gray-700 hover:border-black rounded">
        <div className="grid grid-cols-2 gap-3 p-3">
          {selectedFiles.map((selectedFile, idx) => (
            <div key={idx}>
              <Image
                src={URL.createObjectURL(selectedFile)}
                alt={selectedFile.name}
                width={500}
                height={500}
                quality={100}
                className="object-cover w-full h-auto"
              />
            </div>
          ))}

          {uploadedFiles.map((uploadedFile, idx) => (
            <div key={idx}>
              <Image
                src={uploadedFile}
                alt="An uploaded file"
                width={500}
                height={500}
                quality={100}
                className="object-cover w-full h-auto"
              />
            </div>
          ))}
        </div>
        <div className="flex items-center justify-end gap-x-3 border-t border-dashed p-3 border-gray-700">
          <button
            type="button"
            onClick={onClear}
            className="bg-zinc-300 hover:bg-zinc-200 transition-colors duration-300 px-3 h-10 rounded"
          >
            Clear
          </button>
          {uploadedFiles.length === 0 && (
            <button
              type="button"
              onClick={onUpload}
              className="bg-sky-500 hover:bg-sky-600 transition-colors duration-300 text-white px-3 h-10 rounded"
            >
              Upload
            </button>
          )}
        </div>
      </div>
    );
  }

  return null;
}

index.ts

export * from "./FileUploader";

.eslintrc.json

{
  "extends": "next/core-web-vitals"
}

.prettierrc

{
  "printWidth": 70
}

next.config.mjs

/** @type {import('next').NextConfig} */
const nextConfig = {};

export default nextConfig;

package.json

{
  "name": "codersteps",
  "scripts": {
    "dev": "next dev",
    "build": "next build",
    "start": "next start",
    "lint": "next lint"
  },
  "dependencies": {
    "date-fns": "^3.6.0",
    "mime": "^4.0.4",
    "next": "14.2.4",
    "react": "^18",
    "react-dom": "^18"
  },
  "devDependencies": {
    "@types/mime": "^3.0.4",
    "@types/node": "^20",
    "@types/react": "^18",
    "@types/react-dom": "^18",
    "eslint": "^8",
    "eslint-config-next": "14.2.4",
    "postcss": "^8",
    "sass": "^1.77.8",
    "tailwindcss": "^3.4.1",
    "typescript": "^5"
  }
}

postcss.config.mjs

/** @type {import('postcss-load-config').Config} */
const config = {
  plugins: {
    tailwindcss: {},
  },
};

export default config;

tailwind.config.ts

import type { Config } from "tailwindcss";

const config: Config = {
  content: [
    "./pages/**/*.{js,ts,jsx,tsx,mdx}",
    "./components/**/*.{js,ts,jsx,tsx,mdx}",
    "./app/**/*.{js,ts,jsx,tsx,mdx}",
  ],
  theme: {},
  plugins: [],
};
export default config;

tsconfig.json

{
  "compilerOptions": {
    "lib": ["dom", "dom.iterable", "esnext"],
    "allowJs": true,
    "skipLibCheck": true,
    "strict": true,
    "noEmit": true,
    "esModuleInterop": true,
    "module": "esnext",
    "moduleResolution": "bundler",
    "resolveJsonModule": true,
    "isolatedModules": true,
    "jsx": "preserve",
    "incremental": true,
    "plugins": [
      {
        "name": "next"
      }
    ],
    "paths": {
      "@/*": ["./*"]
    }
  },
  "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
  "exclude": ["node_modules"]
}