A common question for Next.js developers is how to tackle file uploads using the App Router. We'll explore two approaches: server actions and API endpoints.
For this lesson, we'll use a simple Next.js app with Tailwind CSS and ShadCN already set up in the 02-file-uploads
directory of the repo.
Creating the Upload Form
First, let's build a basic UploadForm
client component:
export default function UploadForm() {
return (
<form className="flex flex-col gap-4">
<label>
<span>Upload a file</span>
<input type="file" name="file" />
</label>
<button type="submit">
Submit
</button>
</form>
);
}
This form is barebones, but it provides the foundation for our file upload functionality.
If we open the app in the browser, we can see the form and choose a file, but nothing happens when we submit.
Handling Uploads with Server Actions
Let's create a server action to handle the uploaded file.
First, create a new directory called public/uploads
to store the uploaded files. In a real-world scenario, you'd likely use a cloud storage service like AWS S3, but we'll use a local file because we're in dev mode.
Create a new file app/upload-action.ts
for the server action:
"use server";
import fs from "node:fs/promises";
export async function uploadFile(formData: FormData) {
const file = formData.get("file") as File;
const arrayBuffer = await file.arrayBuffer();
const buffer = new Uint8Array(arrayBuffer);
await fs.writeFile(`./public/uploads/${file.name}`, buffer);
}
This code defines a server action that receives form data, extracts the uploaded file, converts it to a byte array, and saves it to the public/uploads
directory.
Now, we need to wire up the action to our form:
"use client";
import { uploadFile } from "./upload-action";
export default function UploadForm() {
return (
<form action={uploadFile} className="flex flex-col gap-4">
<label>
<span>Upload a file</span>
<input type="file" name="file" />
</label>
<button type="submit">Submit</button>
</form>
);
}
By setting the action
attribute of the form, Next.js takes care of submitting the form data to our server action.
When we upload a file and submit the form, the file is saved to the public/uploads
directory.
Displaying Uploaded Images
Let's display uploaded images on our homepage. The first thing we need to do is iterate through the public/uploads
directory to create a list of image URLs, then map over them and render them below the upload form:
import Image from "next/image";
import fs from "node:fs/promises";
import UploadForm from "./UploadForm";
export default async function Home() {
const files = await fs.readdir("./public/uploads");
const images = files
.filter((file) => file.endsWith(".jpg"))
.map((file) => `/uploads/${file}`);
return (
<main>
<h1>File Upload Example</h1>
<div>
<UploadForm />
</div>
<div className="flex flex-wrap">
{images.map((image) => (
<div key={image} className="px-2 h-auto w-1/2">
<Image
key={image}
src={image}
width={400}
height={400}
alt={image}
className="object-cover w-full"
/>
</div>
))}
</div>
</main>
);
}
The existing images will now show, but uploading a new file won't make it show automatically. To address this, we'll revalidate the homepage route after each upload.
To fix this, we'll add a call to revalidatePath
to the uploadFile
function in app/upload-action.ts
:
export async function uploadFile(formData: FormData) {
// ...existing code
await fs.writeFile(`./public/uploads/${file.name}`, buffer);
revalidatePath("/");
}
Now, after each upload, Next.js will revalidate the homepage, ensuring that the latest images are displayed:
Handling Uploads with API Routes
Now that we've seen how to handle file uploads with server actions, let's explore using an API route.
First, create a new file at api/uploadimage/route.ts
. Inside we'll do the same thing we did in the server action, but this time with a POST and getting the form data from the request:
import { NextResponse } from "next/server";
import { revalidatePath } from "next/cache";
import fs from "node:fs/promises";
export async function POST(req: Request) {
try {
const formData = await req.formData();
const file = formData.get("file") as File;
const arrayBuffer = await file.arrayBuffer();
const buffer = new Uint8Array(arrayBuffer);
await fs.writeFile(`./public/uploads/${file.name}`, buffer);
revalidatePath("/");
return NextResponse.json({ status: "success" });
} catch (e) {
console.error(e);
return NextResponse.json({ status: "fail", error: e });
}
}
Next, modify the UploadForm
component to use our API route. To do this, we'll bring in useRef
and create a new uploadFile
event handler. Then we'll also bring in useRouter
to refresh the page after the upload:
"use client";
import { useRouter } from "next/navigation";
import { useRef } from "react";
// import { uploadFile } from "./upload-action";
export default function UploadForm() {
const fileInput = useRef<HTMLInputElement>(null);
const router = useRouter();
async function uploadFile(
evt: React.MouseEvent<HTMLButtonElement, MouseEvent>
) {
evt.preventDefault();
var formdata = new FormData();
formdata.append("file", fileInput?.current?.files?.[0]!);
await fetch("/api/uploadImage", { method: "POST", body: formdata });
router.refresh();
}
return (
<form
method="POST"
action="/api/uploadImage"
className="flex flex-col gap-4"
>
<label>
<span>Upload a file</span>
<input type="file" name="file" ref={fileInput} />
</label>
<button type="submit" onClick={uploadFile}>
Submit
</button>
</form>
);
}
This updated component sends a POST request to our API route with the file data and refreshes the page to reflect the newly uploaded image.
Both of these approaches are valid, so choose the one that best fits your project's needs.