React

File uploads with a Route Handler in the App Router

FormData in a POST Route Handler, explicit limits and external storage: how to handle file uploads in the App Router without surprises.

Key points

  • Uploads belong in a `POST` Route Handler, not in a Server Action with a large body.
  • Validate size, real type and name before reading the bytes.
  • A serverless filesystem is ephemeral: write to object storage.

A file upload in the App Router goes through a POST Route Handler that reads await request.formData(), checks type and size, and writes to storage. The 1 MB default cap on a Server Action is not a preference: it is a limit.

Why not a Server Action

A Server Action lets you raise bodySizeLimit, but the payload still crosses React's serialisation layer. For large files a Route Handler gives streaming, explicit HTTP statuses and no serialisation in between.

// app/api/upload/route.ts
import { NextResponse } from "next/server";
import { writeFile, mkdir } from "node:fs/promises";
import path from "node:path";

export const runtime = "nodejs";

export async function POST(request: Request) {
  const form = await request.formData();
  const file = form.get("file");
  if (!(file instanceof File)) {
    return NextResponse.json({ error: "missing file" }, { status: 400 });
  }
  if (file.size > 5 * 1024 * 1024) {
    return NextResponse.json({ error: "too large" }, { status: 413 });
  }
  const bytes = Buffer.from(await file.arrayBuffer());
  const name = crypto.randomUUID() + "-" + path.basename(file.name);
  await mkdir(path.join(process.cwd(), "uploads"), { recursive: true });
  await writeFile(path.join(process.cwd(), "uploads", name), bytes);
  return NextResponse.json({ name }, { status: 201 });
}

What to check, really

CheckWhyHow
SizeStops gigabyte uploadsfile.size before reading the bytes
Real typeThe declared header liesMagic bytes or a library such as file-type
File namePath traversalpath.basename plus a UUID
DestinationThe filesystem is ephemeralAn S3 bucket or a mounted volume

A serverless function's filesystem is read-only except /tmp, and /tmp disappears when the instance restarts. In production write to object storage and return a signed URL, not a local path.

Verify with curl

curl -i -X POST http://localhost:3000/api/upload -F "file=@./prova.png"
# 201 con { "name": "..." }
# 400 senza campo file
# 413 oltre 5 MB

The response must be 201 with the name, 400 without a file, 413 past the limit. If you see 500, suspect the runtime first: node:fs does not run on the edge.

The EneUI catalogue indexes 111,985 components, 1,556 blocks and 12,808 icons from 1,472 libraries; the figure was measured on 1 November 2026 on ui.eneawork.it. An upload form is a primitive, not a block: the visual part comes from the catalogue, the network part you write.

Components for the visible part

Three records: the input components for the file field with an error state, the form components for the rest of the form, and the form blocks for a complete section with upload.

If the upload is a form side effect, the choice between action and handler is explained in Route Handler or Server Action. The "window is not defined" error that appears when a browser module is imported into the handler is explained in window is not defined in Next.js. For caching around the response, see the four cache levels.

The limit to budget for

A Route Handler that writes to disk does not scale past one instance. Two requests on two processes write to two different filesystems, and the user sees the file only intermittently.

Frequently asked questions

Can I upload with a Server Action?

Yes, by raising <code>bodySizeLimit</code> in the Next config. Serialisation stays in the path and there is no streaming, so for files above a few MB a Route Handler fits better.

How do I raise the size limit?

In a Route Handler the limit is yours: check <code>file.size</code> and return <code>413</code>. The platform limit, a proxy for example, still applies and must be checked separately.

Why is the file gone after a deploy?

Because the instance filesystem is ephemeral and changes on every deploy or restart. Write to object storage or a mounted persistent volume.

How do I validate the file type?

Read the first bytes and compare them with the expected signature, or use <code>file-type</code>. The <code>type</code> the browser reports is a hint, not proof.

Do I need multi-file upload?

With several files in one <code>formData</code>, read <code>form.getAll("file")</code> and validate each. Beyond a few files, one request per file is better, so one failure does not cancel everything.