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:
"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 }
);
}
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 😉.