# Next.js File Upload: Server Actions, Progress Bars and the 1 MB Limit

> Server actions take files but stop at 1 MB by default and report no progress. For a progress bar, send the file with XMLHttpRequest to a route handler and read xhr.upload.onprogress. If you have a proxy.ts, keep the upload route out of its matcher or raise proxyClientMaxBodySize, or bodies over 10 MB get cut off.

- URL: https://qodesign.dev/blog/nextjs-file-upload-progress-server-action
- Author: Teodor Avadani (https://qodesign.dev/blog/author/teodor-avadani)
- Published: 2026-10-21
- Updated: 2026-10-21
- Category: Next.js fixes
- Publisher: qodesign (https://qodesign.dev), a shop of animated React + Tailwind CSS components for Next.js, each sold with its full source code and an AI prompt.

In Next.js, a server action can take a file, but it can't tell you how far the upload has got, and it refuses anything over 1 MB unless you raise the limit. For a progress bar, post the file to a route handler with `XMLHttpRequest` and read `xhr.upload.onprogress`. `fetch` has no upload progress event. Keep server actions for small files like avatars, where a spinner is enough.

I build upload screens for qodesign, and every snippet below ran on a fresh Next.js 16.4 app with React 19.3 and Tailwind CSS v4: `tsc`, `next build`, `next start`, then headless Chromium with the upload speed throttled so the bar had something to show.

## Can a server action upload a file in Next.js?

Yes. A form with a file input can post straight to a server action, and the file arrives as a `File` in the `FormData`:

```ts
// app/avatar/actions.ts
"use server";

export async function uploadAvatar(formData: FormData) {
  const file = formData.get("avatar");
  if (!(file instanceof File) || file.size === 0) return;
  console.log("server action got", file.name, file.size, "bytes");
  // save it somewhere
}
```

```tsx
// app/avatar/page.tsx
import { uploadAvatar } from "./actions";

export default function Page() {
  return (
    <form action={uploadAvatar}>
      <input type="file" name="avatar" />
      <button type="submit">Upload</button>
    </form>
  );
}
```

With a 500 KB file this works on the first try. The server logged `server action got small.bin 512000 bytes`.

## Why the upload fails over 1 MB

Server actions have a body limit of 1 MB by default. I sent a 2 MB file through the same form and the user got Next's generic error screen ("This page couldn't load. A server error occurred.") with a 500. The real reason only showed in the server log:

```txt
Error: Body exceeded 1 MB limit.
To configure the body size limit for Server Actions, see: https://nextjs.org/docs/app/api-reference/next-config-js/serverActions#bodysizelimit
  statusCode: 413
```

Nothing in the browser says "too big", so this one tends to get reported as "uploads are broken". You can raise the limit in `next.config.ts`:

```ts
import type { NextConfig } from "next";

const nextConfig: NextConfig = {
  experimental: {
    serverActions: {
      bodySizeLimit: "5mb",
    },
  },
};

export default nextConfig;
```

After a rebuild, the same 2 MB file went through (`server action got two.bin 2097152 bytes`). It's still under `experimental` in Next 16, and every action in the app gets the higher limit, not just the upload one. That's fine for avatars and small attachments. For anything bigger, or anything where the user is watching and waiting, use the route below.

## Why a server action can't show upload progress

A server action is called through `fetch` under the hood, and `fetch` in the browser has no event for bytes sent. You find out when the whole request is done. `XMLHttpRequest` still has one: `xhr.upload.onprogress` fires as the body goes out, with `loaded` and `total`. So the client sends the file with XHR to a normal route handler, and React only draws the bar.

## The route handler

Save this as `app/api/upload/route.ts`:

```ts
import { writeFile, mkdir } from "node:fs/promises";
import path from "node:path";
import { NextResponse } from "next/server";

const MAX_BYTES = 50 * 1024 * 1024; // 50 MB

export async function POST(request: Request) {
  // Refuse big bodies before reading them into memory
  const length = Number(request.headers.get("content-length") ?? 0);
  if (length > MAX_BYTES) {
    return NextResponse.json({ error: "File is over 50 MB" }, { status: 413 });
  }

  const form = await request.formData();
  const file = form.get("file");

  if (!(file instanceof File)) {
    return NextResponse.json({ error: "No file" }, { status: 400 });
  }
  if (file.size > MAX_BYTES) {
    return NextResponse.json({ error: "File is over 50 MB" }, { status: 413 });
  }

  // Swap this for S3, R2 or Supabase Storage in production
  const dir = path.join(process.cwd(), "uploads");
  await mkdir(dir, { recursive: true });
  const name = `${Date.now()}-${path.basename(file.name)}`;
  await writeFile(path.join(dir, name), Buffer.from(await file.arrayBuffer()));

  return NextResponse.json({ name, size: file.size });
}
```

Two things in there came from testing. The size check reads `content-length` before `request.formData()`, because once the body is parsed it's already in memory. And I write to a local `uploads` folder only to keep the example short. On a real deploy, send the bytes to S3, R2 or Supabase Storage, because a serverless function's disk doesn't stick around.

A plain `curl -F file=@big.bin` of a 20 MB file came back with `{"name":"…-big.bin","size":20971520}` and the file on disk was exactly 20,971,520 bytes. A file with no `file` field got a 400, and a 60 MB file got the 413 with "File is over 50 MB".

## The upload component with a progress bar

Save this as `components/UploadWithProgress.tsx`. It's a drop zone and a click-to-choose input in one, plus the bar and a cancel button:

```tsx
"use client";

import { useRef, useState } from "react";

type Status = "idle" | "uploading" | "done" | "error";

export function UploadWithProgress() {
  const [progress, setProgress] = useState(0);
  const [status, setStatus] = useState<Status>("idle");
  const [message, setMessage] = useState("");
  const [dragging, setDragging] = useState(false);
  const xhrRef = useRef<XMLHttpRequest | null>(null);

  function upload(file: File) {
    const body = new FormData();
    body.append("file", file);

    const xhr = new XMLHttpRequest();
    xhrRef.current = xhr;
    xhr.open("POST", "/api/upload");

    xhr.upload.onprogress = (e) => {
      if (e.lengthComputable) setProgress(Math.round((e.loaded / e.total) * 100));
    };
    xhr.onload = () => {
      if (xhr.status >= 200 && xhr.status < 300) {
        setStatus("done");
        setMessage(`Uploaded ${file.name}`);
      } else {
        setStatus("error");
        setMessage(`Upload failed (${xhr.status})`);
      }
    };
    xhr.onerror = () => {
      setStatus("error");
      setMessage("Network error");
    };
    xhr.onabort = () => {
      setStatus("idle");
      setMessage("Upload cancelled");
      setProgress(0);
    };

    setStatus("uploading");
    setMessage("");
    setProgress(0);
    xhr.send(body);
  }

  function pick(files: FileList | null) {
    const file = files?.[0];
    if (file) upload(file);
  }

  return (
    <div className="w-full max-w-md space-y-3">
      <label
        onDragOver={(e) => {
          e.preventDefault(); // without this the browser opens the file
          setDragging(true);
        }}
        onDragLeave={() => setDragging(false)}
        onDrop={(e) => {
          e.preventDefault();
          setDragging(false);
          pick(e.dataTransfer.files);
        }}
        className={`flex h-36 cursor-pointer flex-col items-center justify-center rounded-xl border-2 border-dashed text-sm transition-colors ${
          dragging ? "border-indigo-500 bg-indigo-50" : "border-zinc-300"
        }`}
      >
        <span>Drop a file here or click to choose</span>
        <input
          type="file"
          className="sr-only"
          onChange={(e) => {
            pick(e.target.files);
            e.target.value = ""; // lets you pick the same file twice
          }}
        />
      </label>

      {status === "uploading" && (
        <div className="flex items-center gap-3">
          <div
            role="progressbar"
            aria-label="Upload progress"
            aria-valuemin={0}
            aria-valuemax={100}
            aria-valuenow={progress}
            className="h-2 flex-1 overflow-hidden rounded-full bg-zinc-200"
          >
            <div
              className="h-full bg-indigo-500 transition-[width] duration-200"
              style={{ width: `${progress}%` }}
            />
          </div>
          <span className="w-10 text-right text-sm tabular-nums">{progress}%</span>
          <button
            type="button"
            onClick={() => xhrRef.current?.abort()}
            className="text-sm text-zinc-500 underline"
          >
            Cancel
          </button>
        </div>
      )}

      <p aria-live="polite" className="text-sm">
        {message}
      </p>
    </div>
  );
}
```

Use it from any page:

```tsx
// app/upload/page.tsx
import { UploadWithProgress } from "@/components/UploadWithProgress";

export default function Page() {
  return (
    <main className="p-8">
      <UploadWithProgress />
    </main>
  );
}
```

What I checked in Chromium, with uploads throttled to 5 MB/s so a 20 MB file takes a few seconds:

- The bar moved through 42 different values on the way up (0, 2, 5, 7, 10 and so on up to 100), then the text changed to "Uploaded big.bin".
- Cancel in the middle of a 20 MB upload set the text to "Upload cancelled". The server logged `Error: aborted` with `ECONNRESET` for that request, which is the expected sound of a client hanging up and safe to ignore.
- The `onDragOver` handler has to call `preventDefault()`. Without it, dropping a file makes the browser open the file instead of firing `onDrop`.
- Resetting `e.target.value` after a pick lets the user choose the same file again. Otherwise the input sees no change and nothing happens.

The bar uses `role="progressbar"` with `aria-valuenow`, and the result text sits in an `aria-live` region, so a screen reader hears "Uploaded big.bin" when it's done.

## Uploads over 10 MB fail when you have a proxy.ts

This is the one that cost me the most time. If your app has a `proxy.ts` (the file Next 16 uses in place of `middleware.ts`, and the one most auth setups add), its matcher usually covers every route, including `/api/upload`. With a proxy in front, Next only buffers the first 10 MB of a request body. My 20 MB upload failed with a 500, and the server log said:

```txt
Request body exceeded 10MB for /api/upload. Only the first 10MB will be available unless configured.
TypeError: Failed to parse body as FormData.
```

The route got a cut-off body and couldn't parse it. There are two fixes, and I tested both.

Keep the upload route out of the proxy's matcher:

```ts
// proxy.ts
import { NextResponse, type NextRequest } from "next/server";

export function proxy(request: NextRequest) {
  return NextResponse.next();
}

export const config = {
  // Keep the upload route out of the proxy
  matcher: ["/((?!api/upload|_next/static|_next/image|favicon.ico).*)"],
};
```

Or raise the proxy's body limit in `next.config.ts`:

```ts
import type { NextConfig } from "next";

const nextConfig: NextConfig = {
  experimental: {
    serverActions: {
      bodySizeLimit: "5mb",
    },
    proxyClientMaxBodySize: "50mb",
  },
};

export default nextConfig;
```

With either one, the same 20 MB file uploaded and returned 200. I'd use the matcher if the route checks auth itself, because then the proxy has no work to do on uploads at all. With the raised limit, a body over 50 MB is cut at 50 MB again, which is why the route checks `content-length` first. In my test, the 60 MB file got a clean 413 instead of a parse error.

## Which one should you use?

| You're uploading | Use | Why |
|---|---|---|
| An avatar or a small attachment, under a few MB | Server action with a form | Least code. Raise `bodySizeLimit` if you need more than 1 MB. |
| Anything where the user waits and wants to see it move | Route handler and XHR | The only way to get upload progress in the browser |
| Large video or many files | Direct upload to storage with a signed URL | The file never passes through your server, and XHR progress works the same way |

The last row is a different setup I didn't build for this post. The XHR part stays the same; only the URL changes.

## Skip the styling work

If you want the drop zone already designed, the [animated upload dropzone](/react/file-upload-dropzone) is free with source (its [product page](/components/upload-dropzone) has the one-line install), and there's a [Supabase Storage upload](/react/supabase-storage-upload) version if your files go to Supabase. Both are in the free set at [/free](/free). For the toast that says "Uploaded" after the action, see [toasts after a server action](/react/server-action-toast).

## FAQ

### What is the file size limit for Next.js server actions?

1 MB by default. Over that, the action throws "Body exceeded 1 MB limit" with status 413 and the user sees a generic error page. Raise it with `experimental.serverActions.bodySizeLimit` in `next.config.ts`, for example `"5mb"`.

### How do I show upload progress in Next.js?

Send the file with `XMLHttpRequest` to a route handler and listen to `xhr.upload.onprogress`. Divide `loaded` by `total` for the percentage. Server actions and `fetch` don't report upload progress.

### Why does my Next.js upload fail at 10 MB?

You probably have a `proxy.ts` or `middleware.ts` whose matcher includes the upload route. Next only buffers the first 10 MB of the body when a proxy runs, so `request.formData()` fails. Exclude the route from the matcher or set `experimental.proxyClientMaxBodySize`.

### Can I upload files with fetch and still get progress?

Not for uploads. `fetch` can show download progress by reading the response stream, but there's no event for bytes sent. Use `XMLHttpRequest` for the upload, or a library that wraps it.
