codersteps logo

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

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

In this article, I want to share with you how I was able to upload a file easily to your server using Next.js with the new app directory.

Introduction

To make it easy for you to follow, and be able to move what you learn here to other projects, we will build a simple form with file input and an image preview.

We will build a fully functioning image uploader using Next.js and the new route.ts file that comes with the new app directory.

The same principle that you'll learn here can apply to other files as well, file validation may differ but the same flow stays valid.

To make our project look a little nicer and not boring we will use CSS Modules that are already supported by Next.js we just need to install sass to be able to use SCSS instead of just CSS.

Project setup

First, let's create a new Next.js project using the --experimental-app as described in the Beta Next.js Docs.

Go ahead, open your terminal, and navigate to your workspace, then to create a new project run npx create-next-app@latest --experimental-app.

After running the create-next-app command it will prompt you with some simple questions that you may have already seen before, but what is important for us is to use TypeScript, and the src/ directory.

Now it's time to open the newly created project, using your preferred code editor, for me it's vscode and I use code nextjs_app_dir_file_uploader to open my project from my terminal.

Let's install Sass by running npm install -D sass, then rename src/app/page.module.css to src/app/page.module.scss and also update the import inside the src/app/page.tsx file.

File upload form

In this section, we will add a simple form to the homepage, it will have a placeholder image when no image is selected, and a preview of the selected image after it's been uploaded.

Once a file or image in our case is selected, we will validate the file, then we will send the file to the server, in case of success the server should respond with the fileUrl that we will use to preview the uploaded image.

The FileUploader component

As always I will share with you the code, then explain it to you step by step, go ahead and create a new component at src/components/FileUploader.tsx with the code below:

src/components/FileUploader.tsx
"use client";

import Image from "next/image";
import { ChangeEvent, useState } from "react";
import styles from "./FileUploader.module.scss";

export default function FileUploader() {
  const [imageUrl, setImageUrl] = useState("/images/placeholder-image.jpg");

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

    if (!fileInput.files) {
      console.warn("no file was chosen");
      return;
    }

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

    const file = fileInput.files[0];

    const formData = new FormData();
    formData.append("file", file);

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

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

      const data: { fileUrl: string } = await res.json();

      setImageUrl(data.fileUrl);
    } catch (error) {
      console.error("something went wrong, check your console.");
    }

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

  return (
    <label
      className={styles["file-uploader"]}
      style={{ paddingTop: `calc(100% * (${446} / ${720}))` }}
    >
      <Image
        src={imageUrl}
        alt="uploaded image"
        width={720}
        height={446}
        priority={true}
      />
      <input
        style={{ display: "none" }}
        type="file"
        onChange={onImageFileChange}
      />
    </label>
  );
}

It's a simple component even if it looks a little large, we'll go through it step by step until it's clear to you what each part is about.

Let's start with the use client at the top, when using the app directory for routing every component by default is considered like a server component.

So in order to use functionalities that rely on javascript like hooks we need to add the use client to tell Next.js this is a client component.

src/components/FileUploader.tsx
  return (
    <label
      className={styles["file-uploader"]}
      style={{ paddingTop: `calc(100% * (${446} / ${720}))` }}
    >
      <Image
        src={imageUrl}
        alt="uploaded image"
        width={720}
        height={446}
        priority={true}
      />
      <input
        style={{ display: "none" }}
        type="file"
        onChange={onImageFileChange}
      />
    </label>
  );

Our new component simply returns an <Image .../> to preview the uploaded image and a file <input .../> for us to be able to select a file, and as you may have noticed we're hiding it.

We wrap both elements inside a label, so when we click on the image we'll result into showing the browse file popup to select a file.

The main thing here is the onImageFileChange handler that runs after a file is selected, let's see what's going on inside that function.

src/components/FileUploader.tsx
  const [imageUrl, setImageUrl] = useState("/images/placeholder-image.jpg");

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

    if (!fileInput.files) {
      console.warn("no file was chosen");
      return;
    }

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

    const file = fileInput.files[0];

    const formData = new FormData();
    formData.append("file", file);

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

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

      const data: { fileUrl: string } = await res.json();

      setImageUrl(data.fileUrl);
    } catch (error) {
      console.error("something went wrong, check your console.");
    }

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

First, we make sure that a valid file was selected, if no file was selected we just return and log a warning to the console.

Once we have a file, we create a FormData object and add our file to it, then we use the formData object to send our file to the server using fetch and the /api/upload endpoint that we will build next.

After we send the request to our upload api, we check if the response is ok if not we just log an error to the console and return (but you can always handle errors as fit to your project).

When we get a valid response with a fileUrl we use the imageUrl state to show the uploaded image preview.

Maybe you've noticed the import styles from "./FileUploader.module.scss"; at the top, so let's add our component styles.

Create a new file at: src/components/FileUploader.module.scss and add the following to it.

src/components/FileUploader.module.scss
.file-uploader {
  display: block;
  position: relative;
  overflow: hidden;

  img {
    position: absolute;
    top: 50%;
    left: 0;
    width: 100%;
    height: 100%;
    display: block;
    object-fit: cover;
    transform: translateY(-50%);
  }
}

Just some simple css styles to make our image responsive.

Add the FileUploader component to the homepage

Before moving to the next section, where we will build the api endpoint to upload the file to the server, let's add our new component to the homepage, and see what it looks like.

Open the files src/app/page.tsx and replace its content with the following:

src/app/page.tsx
import { Inter } from "next/font/google";
import styles from "./page.module.scss";
import FileUploader from "@/components/FileUploader";

const inter = Inter({ subsets: ["latin"] });

export default function Home() {
  return (
    <main>
      <div className={`${styles.container} ${inter.className}`}>
        <h1>File uploader</h1>
        <form>
          <div>
            <h3>Thumbnail</h3>
            <FileUploader />
          </div>
        </form>
      </div>
    </main>
  );
}

I believe it's self-explanatory, First, we import our component, then we render it inside our form, which may be different for your own project.

Next, let's add some styles for our homepage, do the same open, and replace the content of the src/app/page.module.scss file with the following:

src/app/page.module.scss
.container {
  margin: 0 auto;
  max-width: 480px;

  h1 {
    padding: 40px 0;
  }
}

The upload api endpoint

While in the previous file upload article How To Build A File Uploader With Next.Js And Formidable we used the formidable npm package to parse our uploaded file, here we won't need any extra package instead we will also use FormData as you will see.

The new Next.js app directory routing introduced a new route.ts file that can be used to easily handle HTTP requests.

Just by exporting a function named for example POST when you want to handle a POST request to that endpoint.

Installing dependencies

We will need to install the mime and the date-fns npm packages, which will help us when generating our file name.

We will use the mime package to extract our file extension from the file mime-type.

And we will use the date-fns package to format today's date in order to group the uploaded files by day in a folder.

Go ahead and run npm install mime date-fns Once it's done installing, run npm install -D @types/mime to install the required types.

The upload api handler

In Next.js 13 with the app directory, we have access to the web request API which includes the formData object that we sent using the form we built in the previous section.

The formData object contains our file as a Blob which is all we need to save the uploaded file to our server disk.

In the previous section, we used the endpoint api/upload to upload our image, now we'll create that endpoint.

Let's create the upload handler file at: src/app/api/upload/route.ts with the following code, then we'll explore what each part does.

src/app/api/upload/route.ts
import mime from "mime";
import { join } from "path";
import { stat, mkdir, writeFile } from "fs/promises";
import * as dateFn from "date-fns";
import { NextRequest, NextResponse } from "next/server";

export async function POST(request: NextRequest) {
  const formData = await request.formData();

  const file = formData.get("file") as Blob | null;
  if (!file) {
    return NextResponse.json(
      { error: "File blob is required." },
      { status: 400 }
    );
  }

  const buffer = Buffer.from(await file.arrayBuffer());
  const relativeUploadDir = `/uploads/${dateFn.format(Date.now(), "dd-MM-Y")}`;
  const uploadDir = join(process.cwd(), "public", relativeUploadDir);

  try {
    await stat(uploadDir);
  } catch (e: any) {
    if (e.code === "ENOENT") {
      await mkdir(uploadDir, { recursive: true });
    } else {
      console.error(
        "Error while trying to create directory when uploading a file\n",
        e
      );
      return NextResponse.json(
        { error: "Something went wrong." },
        { status: 500 }
      );
    }
  }

  try {
    const uniqueSuffix = `${Date.now()}-${Math.round(Math.random() * 1e9)}`;
    const filename = `${file.name.replace(
      /\.[^/.]+$/,
      ""
    )}-${uniqueSuffix}.${mime.getExtension(file.type)}`;
    await writeFile(`${uploadDir}/${filename}`, buffer);
    return NextResponse.json({ fileUrl: `${relativeUploadDir}/${filename}` });
  } catch (e) {
    console.error("Error while trying to upload a file\n", e);
    return NextResponse.json(
      { error: "Something went wrong." },
      { status: 500 }
    );
  }
}

As mentioned before the route.ts allow us to handle HTTP verbs by exporting a function with the HTTP verb name that will be used as that HTTP verb handler.

Here we use the POST verb, so we export a POST function like export async function POST(request: NextRequest) {..} and it can be an async function.

After that, we extract our formData object from the request, that was passed to us by Next.js, then we get our uploaded file using formData.get("file").

The key file should match the used key on the client form, then we're doing a simple validation, so in case we don't have a file we respond with a 400 code.

It's good to the NextResponse instead of just Response to avoid some typing issues, for example doing Response.json({..}) is valid but causes a type issue.

And because we know that the file we're sending is a blog (const file = fileInput.files[0];) we type it using Blob or null in case no file was sent.

src/app/api/upload/route.ts
const formData = await request.formData();

const file = formData.get("file") as Blob | null;
if (!file) {
  return NextResponse.json(
    { error: "File blob is required." },
    { status: 400 }
  );
}

In order for us to save our Blob file to the disk we need to cast it to a Buffer, and for that, we use const buffer = Buffer.from(await file.arrayBuffer());.

I always like to organize the uploaded files into directories, a directory per day similar to my other article where we use the Formidable package.

Note: in this demonstration project, we will store the uploaded file under the public directory like /public/uploads/... which is fine when we run the app in development mode.

When our Next.js application runs on a production build, we should consider using a separate server to serve our uploads folder (like nginx using the alias to serve static files).

After we've converted our file to Buffer and generated our uploadDir path, it's time to make sure our uploadDir exists, if not we create it to avoid any access errors.

src/app/api/upload/route.ts
const buffer = Buffer.from(await file.arrayBuffer());
const relativeUploadDir = `/uploads/${dateFn.format(Date.now(), "dd-MM-Y")}`;
const uploadDir = join(process.cwd(), "public", relativeUploadDir);

try {
  await stat(uploadDir);
} catch (e: any) {
  if (e.code === "ENOENT") {
    await mkdir(uploadDir, { recursive: true });
  } else {
    console.error(
      "Error while trying to create directory when uploading a file\n",
      e
    );
    return NextResponse.json(
      { error: "Something went wrong." },
      { status: 500 }
    );
  }
}

Now that we made sure we have a Buffer file, and an upload directory, we generate the final filename, and we use the writeFile from fs/promises to save our file to the uploads directory on disk.

Finally, we send back the url the newly saved file, and in case any unexpected exception occurs we just return a generic 500 error code, and we log to console the exception to help us with debugging.

src/app/api/upload/route.ts
try {
    const uniqueSuffix = `${Date.now()}-${Math.round(Math.random() * 1e9)}`;
    const filename = `${file.name.replace(
      /\.[^/.]+$/,
      ""
    )}-${uniqueSuffix}.${mime.getExtension(file.type)}`;
    await writeFile(`${uploadDir}/${filename}`, buffer);
    return NextResponse.json({ fileUrl: `${relativeUploadDir}/${filename}` });
  } catch (e) {
    console.error("Error while trying to upload a file\n", e);
    return NextResponse.json(
      { error: "Something went wrong." },
      { status: 500 }
    );
  }

Conclusion

At this point, you should have a fully functional file uploader based on the Next.js app directory, which can be used as a starting point for your own project.

Source code is available at: https://github.com/codersteps/nextjs_app_dir_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 😉.

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.