codersteps logo

Building a Multi File Uploader from scratch with Next.js app directory

Abdessamad Ely
Abdessamad Ely
Software Engineer
Reading time: 16 min read  •  Published: in Next.js  •  Updated:

Learn how to build a Multiple File Uploader with Next.js App Router, with a practical step-by-step explanation resulting into a a fully working example at the end.

Introduction

To make it easy for you to follow along, and be able to migrate the skills you learn here to your project and your use case, we will build a basic multiple file uploader from start to finish.

Requirements

In order to follow along with this tutorial, you need to be familiar with the basic of Next.js app router.

Also, you need to be familiar with creating Next.js project and it's default file structure.

What we will build?

We will create a new page /uploader, with a simple form to upload images to the server, all using the Next.js app router.

While in our example, we will upload image files, the same can be applied when uploading other files, with a small difference when previewing/validating the uploaded files.

When dealing with files in Node.js environment, we have a Blob object that we can send to our server regardless of its type.

What we will use for Styling?

I know we're building a file uploader and you may not be interested in how it will look as long as it works.

But, I think some of you may appreciate a styled example for learning rather than a basic unstyled form, also we're going to have a preview feature before and after the image is uploaded.

For that we will use Tailwind CSS which is installed by default with a new Next.js project.

Figma design

Using Figma I was able to design a simple design that we can use with our example project, It's always a good idea to have a design to use as reference when coding.

Our FileUploader component will have 3 states, a state when we first visit the uploader page, another when we select our image files, and final state when the files are uploaded.

The following are the Figma designs for the 3 states mentioned previously:

Initial state

The initial state is before we choose any file, and it will look something like the following design:

Image file uploader initial state design

Preview state

The preview state is after we choose a file (image, can be multiple images), and it will look something like the following design:

Image file uploader preview state design

Uploaded state

The uploaded state is after we choose a file, click on the Upload button and it will look something like the following design:

Image file uploader uploaded state design

As you see it's similar to the preview state design, except that we only have the Replace button.

Project setup

Please feel free to bypass this section if you already have a Next.js project up and running.

Now that we have an idea about what we are going to build, let's start by creating a new Next.js project with app router.

Open your terminal, navigate to your workspace where you want to create your project, then run npx create-next-app@latest to create a new Next.js project.

The create-next-app command will ask you some questions related to the project configuration, go ahead and give the project a name, the accept all the default choices.

Clean up

The default Next.js project comes with a homepage and some styling, the first thing I always like to do is to clean that up and start fresh.

Replace the homepage content with the following:

app/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>
  );
}

Here we're just adding a simple landing page with a title and a link to our uploader page which we will create later.

Now lets cleanup our Tailwind CSS setup by replacing the content for app/globals.css, and tailwind.config.ts with the following:

app/globals.css
@tailwind base;
@tailwind components;
@tailwind utilities;
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;

To make our project look good, lets center the content globally using flex-box, to do that replace or adjust the content for the app/layout.tsx file with the following:

app/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>
  );
}

You may notice that we removed the default Inter Google Font, so our project will use the default system font, but feel free to keep it.

Creating the /uploader page

Like we mentioned before we will have a dedicated page for our image uploader, while we are in the setup phase let's go ahead and create a brand new file at app/uploader/page.tsx.

app/uploader/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>
  );
}

The new page will be responsible for rendering the FileUploader component that we will build late in this tutorial.

For now, we can create the FileUploader component file at components/FileUploader.tsx with a basic setup like the following:

components/FileUploader.tsx
"use client";

export function FileUploader({ apiUrl }: { apiUrl: string }) {
    return <div>FileUploader placeholder: {apiUrl}</div>;
}

The "use client"; here is needed because we will use client side code later, as well as hooks to manage states which also requires the component to be a client-side component.

You may have noticed that we are able to import our component like import { FileUploader } from "@/components"; without specifying the component file name.

For that to work, we need to export the FileUploader component without the default keyword, as well as having and index.ts in the components directory.

Let's do that by creating that file, and use it to export all named exports from our FileUploader component:

components/index.ts
export * from "./FileUploader";

Installing dependencies

Because, I have already built the project, I know what dependencies the project uses, I though it would be better to install all of them in the setup phase.

Installing all dependencies before hand will help us focus more on how the file uploader logic works instead of getting distracted each time a new dependency is introduced.

Mime

The mime npm package is helpful when dealing with files mime-type and extension.

We will use this package later to extract the extension of the uploaded files from their type when generating a unique filename for each uploaded file.

To install the mime package, need to run npm install mime date-fns then to install its types we need to run npm install -D @types/mime.

File uploader API route

In order for the FileUploader component to work, we will need an upload API on our project.

For that, before trying to work on its implementation lets first create our API route at app/api/upload/route.ts with the following content:

app/api/upload/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) {
  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;
  }
}

The uploaded files are not accessible on production

At the top we have defined the constant UPLOAD_DIR to store the location where to write the uploaded files.

Storing the uploaded files under the public/uploads directory will work fine on development as there's no cache involved.

But when we build (npm run build) our Next.js project, then start it (npm start), Next.js seem to remember the files under the public directory.

As a result any newly uploaded file will not be available until you restart the Next.js server (no need to rebuild).

As a solution we can use Nginx on production to serve our uploaded files outside the scope of our Next.js application.

To not make this article too long and complex, I explained how to solve this issue in this article: Building a Static Server for Next.js Uploaded Files.

Handling the API Post request

In Next.js app router the file app/api/upload/route.ts will generate an endpoint at /api/upload by default it doesn't handle any request.

To handle a request we need to export an async function with an HTTP verb/method as its name, in our case we will use the POST method to handle our file upload.

app/api/upload/route.ts:POST
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
    ),
  });
}

The POST function accept the request as an argument, we can use the standard Web API methods to get the request body.

Here we use the formData method to retrieve the request body, which includes our files.

After that, we do some simple validations that you can extend to match your requirements.

Once the validation is done, we loop through the uploaded files, make sure the file is of type Blob before trying to save it to our disk.

Once we confirm that the file is of type Blob its type to call the uploadFile function that will save our file on disk.

You may have noticed that we don't await the function call, instead we push it to the uploadFilePromises array.

This is to write multiple files to the disk at same time, which will result in faster result.

At the end, we await Promise.all giving it our list of promises returned from the uploadFile function to make sure we don't respond to the client before the promise is resolved.

As we see next, the uploadFile function may resolve to a string a url in case of success or false in case of failure.

And because we're returning a list of urls for the uploaded files, we're filtering out any failures.

The uploadFile function

The uploadFile function is responsible for saving a file of type Blob with a unique name under the provided UPLOAD_DIR.

This function expects that we manually created the uploads directory defined in the UPLOAD_DIR constant.

app/api/upload/route.ts:uploadFile
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;
  }
}

To easily understand what's happening here, you can think of it in a 3 steps:

Convert Blob to Buffer

In Node.js to write a file to disk, we can use the fs/promises.writeFile function, which doesn't support a Blob as its data.

The fs/promises.writeFile function supports Buffer as its data, alongside some other formats.

To get a Buffer from Blob we the Buffer.from and give it the arrayBuffer that we retrieve from the Blob: Buffer.from(await file.arrayBuffer()).

Generate a unique filename

To avoid overriding files, we need to make a unique filename for each uploaded file.

To achieve that, we divid the filename into 3 parts: original filename, a unique suffix, and the file extension.

First, we generate a unique suffix combining the current timestamp, with a random string.

We can get the original filename using file.name even if TypeScript doesn't see that, because the Blob type doesn’t have a name attribute on it.

We know that our file have the name attribute, so to avoid any TypeScript we check first using "name" in file && typeof file.name === "string".

It maybe confusing but what's happening is that we have some types differences between Node.js and the Browser.

In the browser, we have the File type which extends the Blob type, and adds to it some extra attributes like name.

In Node.js we only have the Blob type, so we know that we sent a File from our client, which includes the name but Node.js doesn't show it.

Lastly, we use the mime package to extract the file extension from the file.type, then we join them together resulting in a unique filename.

Write the file to disk

After we generated a unique filename, and we converted our Blob to a Buffer, it's time to use them to write the file to disk.

As mentioned before, we will use the fs/promises.writeFile function giving it the absolute path where to store the file, and the buffer as the file data.

If the writeFile function doesn’t throw an exception it means its a success, and we can return the relative path.

In case it throws an error it means it was a failure, and we can return a false. the failure maybe related the uploads directory doesn't exists, but you can check the console to verify.

FileUploader component implementation

Now that we have our API working and ready, we can now work on our FileUploader component.

In the setup phase we created the FileUploader component, but we didn't implement anything related to choosing, previewing or uploading files.

To make it easy for you to understand, we will go step by step, as described in the Figma design section.

Ability to select images

components/FileUploader.tsx
"use client";

import { ChangeEvent, useState } from "react";

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

  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));
  }

  if (selectedFiles.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>
    );
  }

  return <div>FileUploader placeholder: {apiUrl}</div>;
}

First, we define the state selectedFiles to keep track of the selected files, then we simple add a label with an input for type file.

We set the attribute accept="image/*" to only allow images, but you can extend or change it depending on your use-case.

We also added the multiple attribute to allow selecting multiple images, as our API supports uploading multiple files.

Then we define the onChange function and attach it to the input onChange event, so its called every time the input changes.

The onChange handler, will extract the chosen files, and assign them to the selectedFiles state.

This will result in hiding the initial state of the component, for now it will fallback to the previous <div>FileUploader placeholder: {apiUrl}</div>;.

Preview the selected images

Once we have some selected files, the initial state will be hidden, and the preview state will be shown instead.

components/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[]>([]);

  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));
  }

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

  if (selectedFiles.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) {
    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>
          ))}
        </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>
        </div>
      </div>
    );
  }
}

When we have some selected files selectedFiles.length > 0 we preview them, and add a new CTA to clear the selectedFiles.

The selectedFiles state is a list of files, which cannot be passed directly to the Image component, so we need to convert each file to a url using URL.createObjectURL.

Now that we preview all the selected images. Just after images grid we add a new button to clear the selected images.

To clear the selected images, we defined a new handler onClear and attached it to the Clear button onClick event.

The onClear handler simply set the selected images to and empty array, which will result in going back to the initial state where we can select new files.

Upload the selected images

Now that we are able to select and preview images, it's time to add the Upload functionality and finalize our component.

components/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;
}

Before explaining what we've added, if you try to upload the selected files, and you didn't create the /public/uploads directory you will face the following error:

Error coming from our upload API: Error: ENOENT: no such file or directory ..., to solve that just go ahead and create the uploads directory and try again.

Now that all is working as expected let's understand what we've added, we'll break it up into two steps as follow:

The uploadedFiles state

Same to what we did for the preview, we need a new state to keep track of the uploaded files urls returned by the upload API.

For that, we created a new state uploadedFiles, and used it to extend the conditions for the component to be aware we're on the uploaded state.

Setup the Upload button

Next to our Clear button we added a new button upload, and attached to its onClick event the function on onUpload.

Next, we created a new function uploadSelectedFiles which is responsible for uploading the selected files using our API, and return the uploaded files.

The onUpload handler uses the uploadSelectedFiles function to uploaded the files and update the uploadedFiles state, the clear the selected files to preview the uploaded files instead of selected ones.

The uploadSelectedFiles function

The uploadSelectedFiles function will simply use the selected files from the selectedFiles state, and the apiUrl passed as a property.

First it does some validation that can be changed to meet your needs, then create a FormData object and attach the files to it.

Its important to keep in mind that the provided key to the formData files need to match what we used in the API:

formData.append("files", file); and formData.getAll("files"); both uses the same key files.

Cloning the project

You can clone the final project using: git clone --branch building-a-file-uploader-with-nextjs-app-router https://github.com/abdessamadely/codersteps.git building-a-file-uploader-with-nextjs-app-router

Conclusion

In this article we have learned how to build a files uploader using Next.js app router.

We started by setting up our project, creating an API, and finally we built a component that uses the API to upload the selected files.

In our example we used files of type image, but same principles apply to all other files types.

If you have a question about this article, please use the form at bottom to submit your question.

Abdessamad Ely
Abdessamad Ely
Software Engineer

A web developer from Morocco Morocco flag , and the founder of coderstep.com a website that help developers grow and improve their skills with a set of step-by-step articles that just works.

Your Questions

  • No questions have been asked yet.

Submit your question

Please use the form bellow to submit your question, and it will be include in the list asap.