Handle file uploads in Next.js without breaking your app or your sanity. A beginner-friendly guide to uploading images and documents the right way.

File uploads look simple on the surface.

Add an input, send it to the server, done.

Until you actually build it.

Suddenly you're dealing with FormData, server parsing, file storage, size limits, and confusing errors when nothing shows up where you expect.

If you're using modern Next.js with the App Router, the good news is that file uploads are much more straightforward than they used to be.

Let's break it down into a simple, working process.

Step 1: Understand the Flow

Before writing code, get clear on what happens during a file upload.

• User selects a file • Browser sends it using FormData • API route receives the file • Server processes and stores it • Response confirms success

That's the entire system.

Once this mental model is clear, everything else becomes easier.

Step 2: Create the Upload API Route

In Next.js, your backend lives inside app/api.

Create a route:

app/api/upload/route.ts

Now handle the POST request:

import { NextResponse } from "next/server";


export async function POST(req: Request) {
  const formData = await req.formData();
  const file = formData.get("file") as File;

  if (!file) {
    return NextResponse.json({ error: "No file uploaded" }, { status: 400 });
  }

  const bytes = await file.arrayBuffer();
  const buffer = Buffer.from(bytes);

  // For now, just log file info
  console.log(file.name, file.type, buffer.length);

  return NextResponse.json({ success: true });
}

That's already enough to receive files.

No extra libraries required for basic uploads.

Step 3: Create the Upload Form

Now let's build the frontend.

"use client";

import { useState } from "react";

export function UploadForm() {
  const [loading, setLoading] = useState(false);

  async function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
    e.preventDefault();
    setLoading(true);

    const formData = new FormData(e.currentTarget);
    await fetch("/api/upload", {
      method: "POST",
      body: formData,
    });

    setLoading(false);
  }

  return (
    <form onSubmit={handleSubmit}>
      <input type="file" name="file" required />
      <button disabled={loading}>
        {loading ? "Uploading..." : "Upload"}
      </button>
    </form>
  );
}

The important part here is FormData.

You don't manually set headers. The browser handles everything.

Step 4: Save Files Locally (Basic Setup)

For learning or small apps, you can store files locally.

Update your API route:

import { writeFile } from "fs/promises";
import path from "path";

export async function POST(req: Request) {
  const formData = await req.formData();
  const file = formData.get("file") as File;

  const bytes = await file.arrayBuffer();
  const buffer = Buffer.from(bytes);

  const filePath = path.join(process.cwd(), "public/uploads", file.name);
  await writeFile(filePath, buffer);

  return NextResponse.json({ success: true });
}

Now your files are saved inside public/uploads.

You can access them via /uploads/filename.

It feels satisfying the first time this works.

Step 5: Handling Images vs Documents

The process is the same for images and documents.

The only difference is how you validate and use them.

You can restrict file types like this:

if (!file.type.startsWith("image/")) {
  return NextResponse.json({ error: "Only images allowed" }, { status: 400 });
}

Or allow documents:

const allowedTypes = ["application/pdf", "application/msword"];

if (!allowedTypes.includes(file.type)) {
  return NextResponse.json({ error: "Invalid file type" }, { status: 400 });
}

This prevents users from uploading unexpected files.

Step 6: Handle File Size Limits

This is one of those things you forget until it breaks.

Add a simple check:

const MAX_SIZE = 5 * 1024 * 1024; // 5MB

if (file.size > MAX_SIZE) {
  return NextResponse.json({ error: "File too large" }, { status: 400 });
}

Without this, users can upload huge files and slow down your server.

Step 7: Real-World Storage (Important)

Saving files locally works, but it's not ideal for production.

If your app restarts or scales, those files can disappear.

In real apps, you'll use cloud storage:

• AWS S3 • Cloudinary • Firebase Storage

The upload flow stays the same.

Only the storage step changes.

Instead of writeFile, you upload the buffer to a cloud service.

Step 8: Improve UX (Optional but Worth It)

Basic uploads work, but small improvements make a big difference.

You can:

• Show file name before upload • Add preview for images • Display success or error messages • Disable button during upload

Example:

<input type="file" name="file" onChange={(e) => {
  const file = e.target.files?.[0];
  console.log(file?.name);
}} />

These details make your app feel more polished.

Common Beginner Mistakes

A few things that usually go wrong:

• Trying to send JSON instead of FormData • Manually setting Content-Type headers • Forgetting to handle large files • Mixing client and server logic

If your file is undefined on the server, it's almost always a FormData issue.

Final Thoughts

File uploads in Next.js feel complicated until you understand the flow.

Once it clicks, it's just:

Receive the file. Validate it. Store it. Respond.

That's it.

Start simple with local storage, then move to cloud solutions as your app grows.

And the next time you see a file upload feature in a product, you'll know exactly what's happening behind the scenes.