It may be tricky to create a file uploader with Next.js. I will teach you how to build a file uploader with Next.js and formidable with practical step-by-step explanations for each step.

So you will be confident to use the same knowledge to create a flexible file uploader that satisfies your client's needs.

File uploader basic features

Before we start coding let's discuss what we need from our file uploader, and let's set some goals to achieve at the end of this article.

At its core, a file uploader should move a file from our computer to our web server, in another word it needs to upload a file.

A file uploader should validate what files it can accept, so we won't allow users to send us executable files, and hack our server its the least we can do for security sakes.

A file uploader needs to have a nice user interface, and we will use tailwind css to make our file uploader responsive and beautiful.

And finally, because files are not fun to work with, we will use images, so we will build an image file uploader. This means a file uploader should have a preview before sending files to the server.

And as a bonus, we will create a helper that reads the uploaded images from the uploads directory and show them to a page using Next.js getServerSideProps function.

Setting up our project

In this project, we will use create-next-app with the --typescript flag, which creates a Next.js with Typescript instead of JavaScript after that we will install dependencies as we go.

Without further ado let's start. Open your terminal and navigate to where you keep your projects.

Create a new Next.js project with Typescript support by running this command: npx create-next-app@latest --typescript file_uploader.

After the command is completed open it in your favorite code editor, for me it's visual studio code so I will run code file_uploader to open it.

Before installing any new dependency let's first clean the project, by opening the pages/index.tsx file and replacing its content with the following.

pages/index.tsx
TypeScript
import type { NextPage } from "next";
import Head from "next/head";

const Home: NextPage = () => {
  return (
    <div>
      <Head>
        <title>File uploader</title>
        <meta name="description" content="File uploader" />
      </Head>

      <main>
        <h1>Upload your files</h1>

        <form action="">
          <input name="file" type="file" />
        </form>
      </main>

      <footer>
        <p>All right reserved</p>
      </footer>
    </div>
  );
};

export default Home;

Because we are going to use tailwind css we don't need the css modules file at styles/Home.module.css so feel free to delete it.

After we cleaned up the homepage, we will create the upload API file at pages/api/upload.ts let's create a simple upload endpoint that returns the string hello file uploader always go slowly and know your steps.

You can also just rename the api/hello.ts file to api/upload.ts and you're done with this one, but just in case, here's the file content.

pages/api/upload.ts
TypeScript
import type { NextApiRequest, NextApiResponse } from "next";

type Data = {
  name: string;
};

export default function handler(
  req: NextApiRequest,
  res: NextApiResponse<Data>
) {
  res.status(200).json({ name: "hello file uploader" });
}

Now let's start our server using npm run dev to verify that everything is working as expected wait for the server to successfully start then open a new tab and visit http://localhost:3000.

You should see a simple title, file input, and a footer. Now let's test the API endpoint by visiting http://localhost:3000/api/upload you should see something like {"name": "hello file uploader"}.

You may be wondering! why we're able to access the upload API using a GET request directly from the browser? That's because we accept all requests and we respond with the same data, but we will fix it and allow only POST requests.

Now that we have an API endpoint and a homepage to show the file uploader form, let's go one step further and set up tailwind css.

Styling the upload file form with Tailwind CSS

Thanks to the Tailwind CSS team, it's a simple procedure and they have a guide for Next.js, so we will just follow their guide.

First, we need to install it by executing npm install -D tailwindcss postcss autoprefixer

Then npx tailwindcss init -p to generate the tailwind.config.js and postcss.config.js files. After it's done open the tailwind.config.js file and replace the content with the following.

tailwind.config.js
JavaScript
module.exports = {
  content: [
    "./pages/**/*.{js,ts,jsx,tsx}",
    "./components/**/*.{js,ts,jsx,tsx}",
  ],
  theme: {
    extend: {},
  },
  plugins: [],
}

Still, one more thing is to include the tailwindcss directives to our global styles, to do that we will open styles/global.css and replace its content with the following.

styles/global.css
CSS
@tailwind base;
@tailwind components;
@tailwind utilities;

Now we are ready to start using tailwindcss classes to style out file uploader, If you are already comfortable using tailwind please fill free to use your own styling 😉.

After styling the file upload form, we end up with the following code, try it out and see how it looks.

pages/index.tsx
TypeScript
import type { NextPage } from "next";
import Head from "next/head";

const Home: NextPage = () => {
  return (
    <div>
      <Head>
        <title>File uploader</title>
        <meta name="description" content="File uploader" />
      </Head>

      <main className="py-10">
        <div className="w-full max-w-3xl px-3 mx-auto">
          <h1 className="mb-10 text-3xl font-bold text-gray-900">
            Upload your files
          </h1>

          <form
            className="w-full p-3 border border-gray-500 border-dashed"
            action=""
          >
            <div className="flex flex-col md:flex-row gap-1.5 md:py-4">
              <label className="flex flex-col items-center justify-center flex-grow h-full py-3 transition-colors duration-150 cursor-pointer hover:text-gray-600">
                <svg
                  xmlns="http://www.w3.org/2000/svg"
                  className="w-14 h-14"
                  fill="none"
                  viewBox="0 0 24 24"
                  stroke="currentColor"
                  strokeWidth={2}
                >
                  <path
                    strokeLinecap="round"
                    strokeLinejoin="round"
                    d="M3 15a4 4 0 004 4h9a5 5 0 10-.1-9.999 5.002 5.002 0 10-9.78 2.096A4.001 4.001 0 003 15z"
                  />
                </svg>
                <strong className="text-sm font-medium">Select an image</strong>
                <input className="block w-0 h-0" name="file" type="file" />
              </label>
              <div className="flex mt-4 md:mt-0 md:flex-col justify-center gap-1.5">
                <button
                  disabled={true}
                  className="w-1/2 px-4 py-3 text-sm font-medium text-white transition-colors duration-300 bg-gray-700 rounded-sm md:w-auto md:text-base disabled:bg-gray-400 hover:bg-gray-600"
                >
                  Cancel file
                </button>
                <button
                  disabled={true}
                  className="w-1/2 px-4 py-3 text-sm font-medium text-white transition-colors duration-300 bg-gray-700 rounded-sm md:w-auto md:text-base disabled:bg-gray-400 hover:bg-gray-600"
                >
                  Upload file
                </button>
              </div>
            </div>
          </form>
        </div>
      </main>

      <footer>
        <div className="w-full max-w-3xl px-3 mx-auto">
          <p>All right reserved</p>
        </div>
      </footer>
    </div>
  );
};

export default Home;

Adding event listeners

Now that we got the basic file uploader design ready, let's add some event listeners to handle file upload and prevent the default behavior of our form.

As you can see from the design we have two buttons one for sending the file to the server and another to cancel the preview.

For now, let's have a console.log to make sure all is good before we proceed, also we will need to define some state to store the preview image url, and to decide whether to show the preview or the upload input.

Let's start with the onFileUploadChange event listener coming from the file input, find the <input /> and assign the onFileUploadChange to it just like in the code below.

pages/index.tsx
TypeScript
const Home: NextPage = () => {
  // defining the onFileUploadChange handler
  const onFileUploadChange = (e: ChangeEvent<HTMLInputElement>) => {
    console.log("From onFileUploadChange");
  };

  // ... the other code here
  <input
    className="block w-0 h-0"
    name="file"
    type="file"
    onChange={onFileUploadChange}
  />
  // ... the rest of the code
}

Now let's do the same with both buttons Cancel file and Upload File

pages/index.tsx
TypeScript
const Home: NextPage = () => {
  const onCancelFile = (e: MouseEvent<HTMLButtonElement>) => {
    e.preventDefault();
    console.log("From onCancelFile");
  };

  const onUploadFile = (e: MouseEvent<HTMLButtonElement>) => {
    e.preventDefault();
    console.log("From onUploadFile");
  };

  // Prevent default form submit, so it won't reload when submitted
  <form
    className="w-full p-3 border border-gray-500 border-dashed"
    onSubmit={(e) => e.preventDefault()}
  >
    // ... other code
    <button
      disabled={true}
      onClick={onCancelFile}
      className="w-1/2 px-4 py-3 text-sm font-medium text-white transition-colors duration-300 bg-gray-700 rounded-sm md:w-auto md:text-base disabled:bg-gray-400 hover:bg-gray-600"
    >
      Cancel file
    </button>
    <button
      disabled={true}
      onClick={onUploadFile}
      className="w-1/2 px-4 py-3 text-sm font-medium text-white transition-colors duration-300 bg-gray-700 rounded-sm md:w-auto md:text-base disabled:bg-gray-400 hover:bg-gray-600"
    >
      Upload file
    </button>

  </form>
  // ... other code
}

Show a preview of the selected image file

Now that we have the event listeners ready, let's start by implementing the onFileUploadChange listener, this is what we want to do.

When a user selects an image file we want to show the image preview, sounds good right. Todo to be able to do that we need to take the uploaded file, then check the file mime type to validate that it's a valid image.

After that, we need to convert the uploaded file to a temporary URL using URL.createObjectURL and then have a condition, using a previewUrl state to show or hide the preview image.

Maybe you have noticed that we disabled both Upload and Cancel buttons, but we don't want them to be always disabled, that's why we will disable the Upload button when there's no uploaded file or in other words no preview url.

Here's the complete pages/index.tsx page with the changes that reflect what we have said above.

pages/index.tsx
TypeScript
import type { NextPage } from "next";
import Head from "next/head";
import Image from "next/image";
import { ChangeEvent, MouseEvent, useState } from "react";

const Home: NextPage = () => {
  const [file, setFile] = useState<File | null>(null);
  const [previewUrl, setPreviewUrl] = useState<string | null>(null);

  const onFileUploadChange = (e: ChangeEvent<HTMLInputElement>) => {
    const fileInput = e.target;

    if (!fileInput.files) {
      alert("No file was chosen");
      return;
    }

    if (!fileInput.files || fileInput.files.length === 0) {
      alert("Files list is empty");
      return;
    }

    const file = fileInput.files[0];

    /** File validation */
    if (!file.type.startsWith("image")) {
      alert("Please select a valide image");
      return;
    }

    /** Setting file state */
    setFile(file); // we will use the file state, to send it later to the server
    setPreviewUrl(URL.createObjectURL(file)); // we will use this to show the preview of the image

    /** Reset file input */
    e.currentTarget.type = "text";
    e.currentTarget.type = "file";
  };

  const onCancelFile = (e: MouseEvent<HTMLButtonElement>) => {
    e.preventDefault();
    console.log("From onCancelFile");
  };

  const onUploadFile = (e: MouseEvent<HTMLButtonElement>) => {
    e.preventDefault();
  };

  return (
    <div>
      <Head>
        <title>File uploader</title>
        <meta name="description" content="File uploader" />
      </Head>

      <main className="py-10">
        <div className="w-full max-w-3xl px-3 mx-auto">
          <h1 className="mb-10 text-3xl font-bold text-gray-900">
            Upload your files
          </h1>

          <form
            className="w-full p-3 border border-gray-500 border-dashed"
            onSubmit={(e) => e.preventDefault()}
          >
            <div className="flex flex-col md:flex-row gap-1.5 md:py-4">
              <div className="flex-grow">
                {previewUrl ? (
                  <div className="mx-auto w-80">
                    <Image
                      alt="file uploader preview"
                      objectFit="cover"
                      src={previewUrl}
                      width={320}
                      height={218}
                      layout="fixed"
                    />
                  </div>
                ) : (
                  <label className="flex flex-col items-center justify-center h-full py-3 transition-colors duration-150 cursor-pointer hover:text-gray-600">
                    <svg
                      xmlns="http://www.w3.org/2000/svg"
                      className="w-14 h-14"
                      fill="none"
                      viewBox="0 0 24 24"
                      stroke="currentColor"
                      strokeWidth={2}
                    >
                      <path
                        strokeLinecap="round"
                        strokeLinejoin="round"
                        d="M3 15a4 4 0 004 4h9a5 5 0 10-.1-9.999 5.002 5.002 0 10-9.78 2.096A4.001 4.001 0 003 15z"
                      />
                    </svg>
                    <strong className="text-sm font-medium">
                      Select an image
                    </strong>
                    <input
                      className="block w-0 h-0"
                      name="file"
                      type="file"
                      onChange={onFileUploadChange}
                    />
                  </label>
                )}
              </div>
              <div className="flex mt-4 md:mt-0 md:flex-col justify-center gap-1.5">
                <button
                  disabled={!previewUrl}
                  onClick={onCancelFile}
                  className="w-1/2 px-4 py-3 text-sm font-medium text-white transition-colors duration-300 bg-gray-700 rounded-sm md:w-auto md:text-base disabled:bg-gray-400 hover:bg-gray-600"
                >
                  Cancel file
                </button>
                <button
                  disabled={!previewUrl}
                  onClick={onUploadFile}
                  className="w-1/2 px-4 py-3 text-sm font-medium text-white transition-colors duration-300 bg-gray-700 rounded-sm md:w-auto md:text-base disabled:bg-gray-400 hover:bg-gray-600"
                >
                  Upload file
                </button>
              </div>
            </div>
          </form>
        </div>
      </main>

      <footer>
        <div className="w-full max-w-3xl px-3 mx-auto">
          <p>All right reserved</p>
        </div>
      </footer>
    </div>
  );
};

export default Home;

Cancel the uploaded image and hide it's preview

Now you can select an image file, and you choose to see a preview for that image, no we need to implement the login for the buttons, Let's start with the simpler one by canceling the selected file when the Cancel button is clicked.

pages/index.tsx
TypeScript
const Home: NextPage = () => {
  // other code ..
  const onCancelFile = (e: MouseEvent<HTMLButtonElement>) => {
    e.preventDefault();
    if (!previewUrl && !file) {
      return;
    }
    setFile(null);
    setPreviewUrl(null);
  };
  // other code ..
}

It's simple, we check if there's no file and no previewUrl if so we return as we don't need to do anything when there's nothing to cancel. Otherwise, we set both the file and the previewUrl to null.

This will hide the preview image and will show the upload zone, so we can select another image file.

Allow only post requests to our upload API

Now let's implement the real stuff for uploading a file to the server, first let me explain to you how everything will work together.

Remember we already defined an API for uploading the file, but it's still empty, just returning a simple string. First, we need to make it handle only POST requests, let's do this first.

pages/api/upload.ts
TypeScript
import type { NextApiRequest, NextApiResponse } from "next";

const handler = async (
  req: NextApiRequest,
  res: NextApiResponse<{
    data: {
      url: string;
    } | null;
    error: string | null;
  }>
) => {
  if (req.method !== "POST") {
    res.setHeader("Allow", "POST");
    res.status(405).json({
      data: null,
      error: "Method Not Allowed",
    });
    return;
  }

  res.status(200).json({
    data: {
      url: "/uploaded-file-url",
    },
    error: null,
  });
}

export const config = {
  api: {
    bodyParser: false,
  },
};

export default handler;

First, we changed the type that our API will return, when everything goes well, we will return a data object with the uploaded file url, but when something goes wrong we will return an error message.

We check after that if the request method is not POST if it's not, then we return a 405 HTTP status which is for a method not allowed. Also, we provide the header Allow with the allowed methods which are in our case only POST is allowed.

If the request is indeed a POST request then we return a placeholder with a static success status and a data object.

At the end of the file, we are exporting a config object, with the api.bodyParser: false, because Next.js has a default body-parser for parsing form data, we have to disable it to be able to parse it ourselves.

If this confuses you, no worries I'm sure you'll get it once we write the code to parse the upcoming multipart/form-data.

Set up formidable and handle errors

To keep our handler a little shorter and cleaner let's create a new file just for parsing our form data and saving the uploaded file to the server storage.

To be able to parse form data, hence the uploaded file we will use a great and very popular npm package called formidable, from your terminal run npm i formidable to install it.

Because we're using TypeScript we need to install formidable types as well, by running this npm command npm i --save-dev @types/formidable.

Now as we said we will create a new file at lib/parse-form.ts from the project root and make sure it's outside the pages directory.

The lib/parse-form.ts will be something like a middleware, but we will give only the request, so it's not responsible for the API response.

Let's simply create the file, and export a simple function that returns a promise then use it inside out upload API to make sure it's called as expected.

lib/parse-form.ts
TypeScript
import formidable from "formidable";
import type { NextApiRequest } from "next";

export const FormidableError = formidable.errors.FormidableError;

export const parseForm = async (
  req: NextApiRequest
): Promise<{ fields: formidable.Fields; files: formidable.Files }> => {
  return new Promise(async (resolve, reject) => {
    resolve({
      files: {},
      fields: {},
    });
  });
};

Then inside our API handler, we first import the parseForm function and the FormidableError provided by the formidable package.

We have exported the FormidableError from lib/parse-form.ts to make it easy for us if we want to add custom properties to it later.

pages/api/upload.ts
TypeScript
import { parseForm, FormidableError } from "../../lib/parse-form";

  // Just after the "Method Not Allowed" code
  try {
    const { fields, files } = await parseForm(req);

    console.log({ fields, files });

    res.status(200).json({
      data: {
        url: "/uploaded-file-url",
      },
      error: null,
    });
  } catch (e) {
    if (e instanceof FormidableError) {
      res.status(e.httpCode || 400).json({ data: null, error: e.message });
    } else {
      console.error(e);
      res.status(500).json({ data: null, error: "Internal Server Error" });
    }
  }

As you can see from the code, we use the try-catch statement to handle the exceptions that we may encounter.

First, we check to see if an exception is related to the FormidableError which may mean the file is not correct or the file is too large. For that, we respond with a 400 Bad Request status.

Otherwise, we send a generic 500 Internal Server Error and we log the error message this can be used later for debugging.

Parse and validate the request body with formidable

Let's use formidable to parse form-data including multipart/form-data, then validate the request and throw an exception if there are invalid data fields or files.

When formidable parse the request body, it parses both fields and files, this means that you can send file metadata from the client like caption, filename, and so on.

Install date-fn and mime packages

To have a consistent directory structure, for each day we will create a new directory, something like /uploads/01-06-2022.

When dealing with formatting dates I like to use date-fn a popular and lightweight package alternative to moment.js.

To install the date-fn package run npm i date-fns.

We also use a conversation for filenames, to make sure it's unique, we combine current_timestamp + random_number + original_filename + file_extension if the original_filename is undefined or empty we replace it with the string unknown.

When dealing with file mime-type and extension I like to use another package called mime and you can install it using npm i mime && npm i --save-dev @types/mime.

Writing the code to parse the request body

Let's write the code first, then I will explain each part of the code and its purpose, go on and open the lib/parse-form.ts then replace it with the following.

lib/parse-form.ts
TypeScript
import type { NextApiRequest } from "next";
import mime from "mime";
import { join } from "path";
import * as dateFn from "date-fns";
import formidable from "formidable";
import { mkdir, stat } from "fs/promises";

export const FormidableError = formidable.errors.FormidableError;

export const parseForm = async (
  req: NextApiRequest
): Promise<{ fields: formidable.Fields; files: formidable.Files }> => {
  return new Promise(async (resolve, reject) => {
    const uploadDir = join(
      process.env.ROOT_DIR || process.cwd(),
      `/uploads/${dateFn.format(Date.now(), "dd-MM-Y")}`
    );

    try {
      await stat(uploadDir);
    } catch (e: any) {
      if (e.code === "ENOENT") {
        await mkdir(uploadDir, { recursive: true });
      } else {
        console.error(e);
        reject(e);
        return;
      }
    }

    const form = formidable({
      maxFiles: 2,
      maxFileSize: 1024 * 1024, // 1mb
      uploadDir,
      filename: (_name, _ext, part) => {
        const uniqueSuffix = `${Date.now()}-${Math.round(Math.random() * 1e9)}`;
        const filename = `${part.name || "unknown"}-${uniqueSuffix}.${
          mime.getExtension(part.mimetype || "") || "unknown"
        }`;
        return filename;
      },
      filter: (part) => {
        return (
          part.name === "media" && (part.mimetype?.includes("image") || false)
        );
      },
    });

    form.parse(req, function (err, fields, files) {
      if (err) reject(err);
      else resolve({ fields, files });
    });
  });
};

Let's break it up

lib/parse-form.ts
TypeScript
const uploadDir = join(
  process.env.ROOT_DIR || process.cwd(),
  `/uploads/${dateFn.format(Date.now(), "dd-MM-Y")}`
);

try {
  await stat(uploadDir);
} catch (e: any) {
  if (e.code === "ENOENT") {
    await mkdir(uploadDir, { recursive: true });
  } else {
    console.error(e);
    reject(e);
    return;
  }
}

First, we created a new constant to store the upload directory absolute path, we check if we have an environment variable called ROOT_DIR we use it, otherwise, we use the project root as a default root_dir.

Then we join the root_dir with the relative upload dir, as we explained above about the directory naming convention.

After that, we check if the directory already exists, we use the node.js stat function for that, it will throw an exception if the path was not found, or there was another error while trying to read the directory stat.

In the case where we have an exception, we check for the error code, if the dir was not found we create it, if not we pass the error to the reject and return, this will return a 500 Internal Server Error response.

lib/parse-form.ts
TypeScript
const form = formidable({
  maxFiles: 2,
  maxFileSize: 1024 * 1024, // 1mb
  uploadDir,
  filename: (_name, _ext, part) => {
		const uniqueSuffix = `${Date.now()}-${Math.round(Math.random() * 1e9)}`;
		const filename = `${part.name || "unknown"}-${uniqueSuffix}.${
			mime.getExtension(part.mimetype || "") || "unknown"
		}`;
		return filename;
  },
  filter: (part) => {
    return (
      part.name === "media" && (part.mimetype?.includes("image") || false)
    );
  },
});

We then create a formidable form and pass the proper configuration for us, you can check the formidable docs to view all the available options.

We override the filename by implementing the filename function, you can also check the formidable docs for more information.

Just one note to keep in mind that the generated filename should be unique to avoid any files gets replaced by new ones.

As explained above about filename conversation. here we implement exactly that, we get the timestamp using Date.now() and generate a random number with the help of the Math object.

Finally, we get the original filename using part.name and get the file extension using the mime package we have installed and the file mime-type from part.mimetype.

In the end, we have implemented the filter function, which will filter the uploaded files and allow only files with the key "media" (which can be the FormData key or the file input name, in case you don't send a file from javascript) and allow only files of type image.

lib/parse-form.ts
TypeScript
form.parse(req, function (err, fields, files) {
  if (err) reject(err);
  else resolve({ fields, files });
});

The formidable form that we have created, has a parse method which takes a request and a callback, here we pass the request that we got from the API endpoint if you remember, and we pass to it a closure which will be called after the form is parsed.

We check if there are errors in case of failure, as usual otherwise, we resolve the promise, passing along the parsed fields and files.

That's it for the lib/parse-form.ts file now we should be able to upload files using our /api/upload endpoint and have access to the file metadata.

Send back the uploaded file path as a response

Now that we can save the file to the storage, we will need to send the path to that file back to the client-side otherwise, it makes nonsense 😉.

Inside our api handler add the code below, it's self-explanatory, we get the uploaded file information from the files object, then we add it to the data.url property.

api/upload.ts
TypeScript
// change the data type to support array of string
data: {
      url: string | string[];
} | null;

// Just after the "Method Not Allowed" code
try {
  const { fields, files } = await parseForm(req);

  const file = files.media;
  let url = Array.isArray(file) ? file.map((f) => f.filepath) : file.filepath;

  res.status(200).json({
    data: {
      url,
    },
    error: null,
  });
}

Because we can upload multiple files, we have to check if the file is an array or not and handle each case.

Here it's your choice to format the file url, the way you want it to be, In this case, we are returning the filepath provided by the formidable package.

Send the selected file to the server with Next.js

To send a file to the server we will use the Fetch API, we previously wrote the code to save the uploaded file to a state.

Let's write the code first and then explain it part by part, we will implement the onUploadFile function which gets called when we click the Upload File button.

pages/index.tsx
TypeScript
const onUploadFile = async (e: MouseEvent<HTMLButtonElement>) => {
  e.preventDefault();

  if (!file) {
    return;
  }

  try {
    let formData = new FormData();
    formData.append("media", file);

    const res = await fetch("/api/upload", {
      method: "POST",
      body: formData,
    });

    const {
      data,
      error,
    }: {
      data: {
        url: string | string[];
      } | null;
      error: string | null;
    } = await res.json();

    if (error || !data) {
      alert(error || "Sorry! something went wrong.");
      return;
    }

    console.log("File was uploaded successfylly:", data);
  } catch (error) {
    console.error(error);
    alert("Sorry! something went wrong.");
  }
};

First, we added the async to be able to use await inside the function, and because the file can be null we do check if there's a file that was selected and if not we just return and ignore the rest.

To be able to send a file to the server we have to use the FormData, we created a new instance and appended to it the file with the key media as you may be noticed we have to use the same key on both client and server.

Using fetch we send a post request with the formData we created, this will send the file to our API which will validate the file and if it's valid it will save it as we explained previously.

If it's you're first using the Fetch API it's simple, you give it the endpoint, then pass the needed options to it, in our case we wanted a request with the POST method, and with a body.

The Fetch API returns a response object with properties like ok, json, text, etc you can check the MDN documentation to learn more about using this API.

The file upload API returns JSON, so we used await res.json() and we destruct the response data to {data, error} using the same types we used in our backend (💁‍♂️ You can create a new type, export it and use inside both files).

Finally, we handle exceptions and errors if there were any here I used the browser alert and console.log but you can use something like toastr or show it under the upload form to meet your requirements.

Multiple file upload

You may be wondering about multiple file uploads, as for now, we're only uploading a single file at a time.

In this section, I want to show you how to upload multiple at once. The good news is that our /api/upload endpoint is already set up for handling multiple files.

Instead of changing our current implementation, we will add a second upload form under the one we already have. So it's easy for you to understand and adapt to your existing app.

Refactoring out homepage to use component

Before we start creating the second upload form, let's move our existing one to its own component.

Go ahead and create a new file at components/SingleFileUploadForm.tsx and move the form from the pages/index.tsx file to the newly created one.

You should have something like the following.

components/SingleFileUploadForm.tsx
TypeScript
const SingleFileUploadForm = () => {
  return (
    <form
      className="w-full p-3 border border-gray-500 border-dashed"
      onSubmit={(e) => e.preventDefault()}
    >
      <div className="flex flex-col md:flex-row gap-1.5 md:py-4">
        {/* ... the rest of the code */}
      </div>
    </form>
  );
};

export default SingleFileUploadForm;

Next, its time to move our imports, states, and event handlers, now we should have something like the following.

components/SingleFileUploadForm.tsx
TypeScript
import Image from "next/image";
import { ChangeEvent, MouseEvent, useState } from "react";

const SingleFileUploadForm = () => {
	const [file, setFile] = useState<File | null>(null);
  const [previewUrl, setPreviewUrl] = useState<string | null>(null);

  const onFileUploadChange = (e: ChangeEvent<HTMLInputElement>) => {
    // ... the function code
  };

  const onCancelFile = (e: MouseEvent<HTMLButtonElement>) => {
    // ... the function code
  };

  const onUploadFile = async (e: MouseEvent<HTMLButtonElement>) => {
    // ... the function code
  };

  return (
    <form
      className="w-full p-3 border border-gray-500 border-dashed"
      onSubmit={(e) => e.preventDefault()}
    >
      <div className="flex flex-col md:flex-row gap-1.5 md:py-4">
				{/* ... the rest of the code */}
      </div>
    </form>
  );
};

export default SingleFileUploadForm;

The last thing to do here is to include our component on the homepage, just like it was before, just this time using a component.

pages/index.tsx
TypeScript
// ...
import SingleFileUploadForm from "../components/SingleUploadForm";

const Home: NextPage = () => {
  return (
    <div>
     	// ...
      <main className="py-10">
        <div className="w-full max-w-3xl px-3 mx-auto">
          <h1 className="mb-10 text-3xl font-bold text-gray-900">
            Upload your files
          </h1>
					
          <SingleFileUploadForm />
        </div>
      </main>
     	// ...
    </div>
  );
};

export default Home;

You can now open your browser and check if it's still working as expected.

Multiple file upload component

Go ahead and create a new file at components/MultipleFileUploadForm.tsx with a simple React component as a starting point.

components/MultipleFileUploadForm.tsx
TypeScript
const MultipleFileUploadForm = () => {
  return <div>Just a placeholder!</div>;
};

export default MultipleFileUploadForm;

We need to include it on our homepage, just after SingleFileUploadForm with adding two titles to identify each one something like the following.

pages/index.tsx
TypeScript
// ...
import MultipleFileUploadForm from "../components/MultipleFileUploadForm";

const Home: NextPage = () => {
  return (
          // ...
          <h1 className="mb-10 text-3xl font-bold text-gray-900">
            Upload your files
          </h1>

          <div className="space-y-10">
            <div>
              <h2 className="mb-3 text-xl font-bold text-gray-900">
                Single File Upload Form
              </h2>
              <SingleFileUploadForm />
            </div>
            <div>
              <h2 className="mb-3 text-xl font-bold text-gray-900">
                Multiple File Upload Form
              </h2>
              <MultipleFileUploadForm />
            </div>
          </div>
          // ...
  );
};

export default Home;

We will reuse some design and logic from our SingleFileUploadForm component, but we will remove the Upload file button. Instead, we want to upload the files as soon as we choose them.

Using the onFilesUploadChange event handler, we're going to get the selected files, validate them then post them to our api/upload.ts endpoint.

After we get a successful response from our API, we're going to convert our selected images with URL.createObjectURL in order to preview them.

In a production environment, you may want your API to return the uploaded files' URLs, which can be used for preview.

Because we're sending files to the server, immediately after they've been selected, we will not need to keep track of them, that's why we'll only keep track of preview URLs.

Multiple file upload design

So our form design we'll be the same, just instead of rendering a single image, we'll render multiple images.

Let's add the code first and then explain it, the following is the full-form design, we will use.

components/MultipleFileUploadForm.tsx
TypeScript
import Image from "next/image";
import { ChangeEvent, useState } from "react";

const MultipleFileUploadForm = () => {
  const [previewUrls, setPreviewUrls] = useState<string[]>([]);

  const onFilesUploadChange = (e: ChangeEvent<HTMLInputElement>) => {
    const fileInput = e.target;

    if (!fileInput.files) {
      alert("No files were chosen");
      return;
    }

    if (!fileInput.files || fileInput.files.length === 0) {
      alert("Files list is empty");
      return;
    }

    /** Files validation */
    const validFiles: File[] = [];
    for (let i = 0; i < fileInput.files.length; i++) {
      const file = fileInput.files[i];

      if (!file.type.startsWith("image")) {
        alert(`File with idx: ${i} is invalid`);
        continue;
      }

      validFiles.push(file);
    }

    if (!validFiles.length) {
      alert("No valid files were chosen");
      return;
    }

    /** Uploading files to the server */
    setPreviewUrls(
      validFiles.map((validFile) => URL.createObjectURL(validFile))
    ); // we will use this to show the preview of the images

    /** Reset file input */
    e.currentTarget.type = "text";
    e.currentTarget.type = "file";
  };

  return (
    <form
      className="w-full p-3 border border-gray-500 border-dashed"
      onSubmit={(e) => e.preventDefault()}
    >
      {previewUrls.length > 0 ? (
        <>
          <button
            onClick={() => setPreviewUrls([])}
            className="mb-3 text-sm font-medium text-gray-500 transition-colors duration-300 hover:text-gray-900"
          >
            Clear Previews
          </button>

          <div className="flex flex-wrap justify-start">
            {previewUrls.map((previewUrl, idx) => (
              <div key={idx} className="w-full p-1.5 md:w-1/2">
                <Image
                  alt="file uploader preview"
                  objectFit="cover"
                  src={previewUrl}
                  width={320}
                  height={218}
                  layout="responsive"
                />
              </div>
            ))}
          </div>
        </>
      ) : (
        <label className="flex flex-col items-center justify-center h-full py-8 transition-colors duration-150 cursor-pointer hover:text-gray-600">
          <svg
            xmlns="http://www.w3.org/2000/svg"
            className="w-14 h-14"
            fill="none"
            viewBox="0 0 24 24"
            stroke="currentColor"
            strokeWidth={2}
          >
            <path
              strokeLinecap="round"
              strokeLinejoin="round"
              d="M3 15a4 4 0 004 4h9a5 5 0 10-.1-9.999 5.002 5.002 0 10-9.78 2.096A4.001 4.001 0 003 15z"
            />
          </svg>
          <strong className="text-sm font-medium">Select images</strong>
          <input
            className="block w-0 h-0"
            name="file"
            type="file"
            onChange={onFilesUploadChange}
            multiple
          />
        </label>
      )}
    </form>
  );
};

export default MultipleFileUploadForm;

First, we've added a state to track the preview URLs, which we set inside the onFilesUploadChange after files validation.

Then we used that state previewUrls to decide whether to show the preview images or the file input.

Using TailwindCSS I have refactored the single file upload form, to a new one with multiple image rendering.

Now, we can select multiple files, after adding the multiple attribute to our input file tag, we do kind of the same validation, but here we use a for loop to validate each file.

We introduced a new array validFiles to store the valid files (images), in case we have an invalid one we alert the user, and we move on.

If all selected files are invalid, then we just alert the user and ignore the rest of the upload process.

Post multiple files to our API

Here it's the same process as in the single upload form, the only difference is adding multiple files to our formData, and as we already saw before we have multiple preview URLs.

The following is how we send multiple files to our server.

components/MultipleFileUploadForm.tsx
JavaScript
// ...

const MultipleFileUploadForm = () => {
  const [previewUrls, setPreviewUrls] = useState<string[]>([]);

  const onFilesUploadChange = async (e: ChangeEvent<HTMLInputElement>) => {
    const fileInput = e.target;
    // ...

    /** Uploading files to the server */
    try {
      var formData = new FormData();
      validFiles.forEach((file) => formData.append("media", file));

      const res = await fetch("/api/upload", {
        method: "POST",
        body: formData,
      });

      const {
        data,
        error,
      }: {
        data: {
          url: string | string[];
        } | null;
        error: string | null;
      } = await res.json();

      if (error || !data) {
        alert(error || "Sorry! something went wrong.");
        return;
      }

      setPreviewUrls(
        validFiles.map((validFile) => URL.createObjectURL(validFile))
      ); // we will use this to show the preview of the images

      /** Reset file input */
      fileInput.type = "text";
      fileInput.type = "file";

      console.log("Files were uploaded successfylly:", data);
    } catch (error) {
      console.error(error);
      alert("Sorry! something went wrong.");
    }
  };

  return (
    <form
      className="w-full p-3 border border-gray-500 border-dashed"
      onSubmit={(e) => e.preventDefault()}
    >
      /...
    </form>
  );
};

export default MultipleFileUploadForm;

We used the forEach loop to iterate through the validFiles, then we used formData.append("media", file) to append each file to our formData object.

Appending multiple values to the same form data key is something kind of equivalent to when we have an input with a name attribute like name="media[]" which sends multiple values with the post body.

Now you should be able to use the second form to upload multiple files to your server, you go ahead and try it out with your browser.

Conclusion

If you followed this article from the beginning you should now have a fully working file uploader using Next.js and formidable.

Source code is available at: https://github.com/codersteps/nextjs_file_uploader.

The next step is to extend your file uploader by building a Next.Js File Upload Progress Bar Using Axios

I hope this was helpful for you, see you at the next one 😉.