Skip to the solution
MediumFrontend9 min read

How do you handle file uploads in Next.js with Server Actions and Route Handlers?

760 views

The problem

Question presented to candidate: "Users need to upload an avatar (a few hundred KB) and, elsewhere, video files of up to 50 MB. How would you handle file uploads in a Next.js App Router app, and when would you use a Server Action versus a Route Handler?"

What a strong answer should cover:

  • A <form> with <input type="file" name="file"> posting to a Server Action: the action receives FormData, formData.get("file") is a File, and the form works with progressive enhancement.
  • Server Action bodies are limited to 1 MB by default (experimental.serverActions.bodySizeLimit), and the limit includes multipart overhead; over it the action fails with "Body exceeded 1 MB limit".
  • Route Handlers have no such built-in limit: await request.formData() for small files, or stream request.body to storage for large ones so the whole file is never held in memory.
  • Validation on the server: type allow-list, size limit, generated file names (never trust file.name for a path), authorization inside the action or handler.
  • For large or many files, prefer direct-to-storage uploads (presigned URLs) so the bytes do not pass through the Next.js server at all.

Clarifying questions expected:

  • "Where do files end up: local disk, S3-compatible storage, a CDN?" β€” local disk does not work on serverless or across instances.
  • "How large, and do users need progress bars or resumable uploads?" β€” decides form + action vs direct-to-storage.

Code / implementation expected: Yes β€” a Server Action upload with useActionState, a request.formData() Route Handler, and a streaming Route Handler.

  • nextjs
  • uploads
  • server-actions
  • route-handlers

Try it yourself

Sketch your approach before you read the solution. That is what the interview tests.

Stuck? Get a hint.

A short nudge toward the approach. It will not give the answer away.

Solution

The solution is hidden so you can try first

9 min read Β· 8 sections

Read the code

Server Action upload with useActionState
// lib/files.ts
import "server-only";
import { mkdir, writeFile } from "node:fs/promises";
import { randomUUID } from "node:crypto";
import path from "node:path";

export const UPLOAD_DIR = path.join(process.cwd(), "uploads");
const ALLOWED = new Map([["image/png", "png"], ["image/jpeg", "jpg"], ["application/pdf", "pdf"]]);
export const MAX_BYTES = 800 * 1024; // our own rule, below the 1 MB Server Action body limit

export type UploadResult = { ok: true; name: string; bytes: number } | { ok: false; error: string };

export async function saveUpload(entry: FormDataEntryValue | null): Promise<UploadResult> {
  if (!(entry instanceof File) || entry.size === 0) return { ok: false, error: "No file" };
  const ext = ALLOWED.get(entry.type);
  if (!ext) return { ok: false, error: `Type ${entry.type || "unknown"} not allowed` };
  if (entry.size > MAX_BYTES) return { ok: false, error: `Too large: ${entry.size} bytes` };
  const bytes = Buffer.from(await entry.arrayBuffer());
  const name = `${randomUUID()}.${ext}`; // never trust entry.name for the path
  await mkdir(UPLOAD_DIR, { recursive: true });
  await writeFile(path.join(UPLOAD_DIR, name), bytes);
  return { ok: true, name, bytes: bytes.length };
}

// app/upload/actions.ts
"use server";
import { saveUpload, type UploadResult } from "@/lib/files";

export async function uploadAction(_prev: UploadResult | null, formData: FormData): Promise<UploadResult> {
  // Real app: verify the session here first; a Server Action is a public POST endpoint.
  return saveUpload(formData.get("file"));
}

// app/upload/upload-form.tsx
"use client";
import { useActionState } from "react";
import { uploadAction } from "./actions";

export default function UploadForm() {
  const [result, formAction, pending] = useActionState(uploadAction, null);
  return (
    <form action={formAction}>
      <input type="file" name="file" accept="image/png,image/jpeg,application/pdf" />
      <button disabled={pending}>{pending ? "Uploading..." : "Upload"}</button>
      <p id="result">{result ? (result.ok ? `saved ${result.name} (${result.bytes} bytes)` : `error: ${result.error}`) : "idle"}</p>
    </form>
  );
}
Route Handlers: FormData and a streamed body
// app/api/upload/route.ts
import { saveUpload, MAX_BYTES } from "@/lib/files";

export async function POST(request: Request) {
  // Cheap early rejection before parsing anything (the header can lie, so saveUpload checks again).
  const declared = Number(request.headers.get("content-length") ?? 0);
  if (declared > MAX_BYTES + 20_000) return Response.json({ error: "Payload too large" }, { status: 413 });
  const form = await request.formData();
  const result = await saveUpload(form.get("file"));
  return Response.json(result, { status: result.ok ? 201 : 400 });
}

// app/api/upload-stream/route.ts
import { createWriteStream } from "node:fs";
import { mkdir, unlink } from "node:fs/promises";
import { randomUUID } from "node:crypto";
import path from "node:path";
import { Readable, Transform } from "node:stream";
import { pipeline } from "node:stream/promises";
import { UPLOAD_DIR } from "@/lib/files";

const LIMIT = 50 * 1024 * 1024; // 50 MB for this endpoint

// Raw body upload (PUT a file as the body): streamed to disk, never held in memory whole.
export async function PUT(request: Request) {
  if (!request.body) return Response.json({ error: "No body" }, { status: 400 });
  await mkdir(UPLOAD_DIR, { recursive: true });
  const file = path.join(UPLOAD_DIR, `${randomUUID()}.bin`);
  let bytes = 0;
  const counter = new Transform({
    transform(chunk, _enc, cb) {
      bytes += chunk.length;
      cb(bytes > LIMIT ? new Error("limit") : null, chunk);
    },
  });
  try {
    await pipeline(Readable.fromWeb(request.body as import("node:stream/web").ReadableStream), counter, createWriteStream(file));
  } catch {
    await unlink(file).catch(() => {});
    return Response.json({ error: `Too large (over ${LIMIT} bytes)` }, { status: 413 });
  }
  return Response.json({ ok: true, bytes }, { status: 201 });
}

Discussion

Other approaches, follow-ups and how it went in real interviews.

Sign in to join the discussion.

No comments yet. Share how you would answer it.

Question 51 of 129 in Next.js

Keep your momentum going.

Mark this question solved to track your progress, then take the next one while it is fresh.

All Next.js questions
Up nextMedium

How does next/font work and why does it improve performance?

Continue