TL;DR 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.
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.
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:
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:
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.
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.
import { writeFile, mkdir } from "node:fs/promises";import path from "node:path";import { NextResponse } from "next/server";const MAX_BYTES = 50 * 1024 * 1024; // 50 MBexport 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".
// app/upload/page.tsximport { 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.
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:
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.tsimport { 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:
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.
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".
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.
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.
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.