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 apply what you learn here on your project, we will build a simple form with file input and an image preview.
The project we'll build uses images, but the same knowledge you learn here can be applied with other files. File validation may differ, but the same logic stays the same
To style our file uploader, we'll use Tailwind CSS which is configured by default when we create our Next.js project.
Project setup
I assume you're already familiar with Next.js, but you can always take a looks at the Next.js getting started docs.
Open your terminal, and navigate to your workspace using cd /your/workspace/path
.
Create a new Next.js project with the npx create-next-app@latest
command.
The create-next-app
command will prompt you with some configuration options, the default options are good.
Once the project is created, please go ahead and open it with your preferred code editor. I use vscode.
Clean up default styles and content
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:
import Link from "next/link";
export default function Home() {
return (
<div>
<h1 className="text-2xl font-bold">
Codersteps project for Building a File Uploader from scratch with Next.js app directory
</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:
@tailwind base;
@tailwind components;
@tailwind utilities;
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:
import type { Metadata } from "next";
import "./globals.css";
export const metadata: Metadata = {
title:
"Codersteps project for Building a File Uploader from scratch with Next.js app directory",
description:
"Codersteps project for Building a File Uploader from scratch with Next.js app directory",
};
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.
File upload form
Here, 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 a fileUrl
that we will use to preview the uploaded image.
The FileUploader component
Lets write the code first, then try to understand it step-by-step, go ahead and create a new component at src/components/FileUploader.tsx
and past the following code:
"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.
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.
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.
.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:
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:
.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.
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.
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.
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.
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 }
);
}
Cloning the project
Source code is available at: https://github.com/codersteps/nextjs_app_dir_file_uploader.
You can clone it with: git clone https://github.com/codersteps/nextjs_app_dir_file_uploader.git
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.
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 😉.