The route handler that streams the file to disk
app/api/upload/route.ts
import { createWriteStream } from "node:fs";
import { mkdir, unlink } from "node:fs/promises";
import { join } from "node:path";
import { Readable } from "node:stream";
import { pipeline } from "node:stream/promises";
import type { ReadableStream as NodeReadableStream } from "node:stream/web";
export const runtime = "nodejs"; // streams to disk, so not the edge runtime
const MAX_BYTES = 200 * 1024 * 1024;
const DIR = join(process.cwd(), "uploads");
// The browser sends the raw file as the body (no FormData), so we can stream it
// to disk without holding it in memory. XMLHttpRequest reports progress on its side.
export async function POST(request: Request) {
// check the session here before accepting anything
const size = Number(request.headers.get("content-length") ?? 0);
if (!size) return Response.json({ error: "Empty upload" }, { status: 400 });
if (size > MAX_BYTES) return Response.json({ error: "Files up to 200 MB" }, { status: 413 });
if (!request.body) return Response.json({ error: "No body" }, { status: 400 });
const name = decodeURIComponent(request.headers.get("x-file-name") ?? "file").replace(/[^\w.-]/g, "_").slice(0, 120);
const id = `${crypto.randomUUID()}-${name}`;
const path = join(DIR, id);
await mkdir(DIR, { recursive: true });
try {
// request.body is a web stream; Readable.fromWeb turns it into a Node stream for the file
await pipeline(Readable.fromWeb(request.body as NodeReadableStream<Uint8Array>), createWriteStream(path));
} catch {
await unlink(path).catch(() => {}); // cancelled or broken upload: drop the partial file
return Response.json({ error: "Upload interrupted" }, { status: 499 });
}
return Response.json({ id, name, size });
}
lib/upload-with-progress.ts
export type Uploaded = { id: string; name: string; size: number };
// fetch() has no upload progress event in browsers. XMLHttpRequest does:
// xhr.upload.onprogress fires as bytes leave the browser.
export function uploadWithProgress(file: File, onProgress: (pct: number) => void, signal?: AbortSignal): Promise<Uploaded> {
return new Promise((resolve, reject) => {
const xhr = new XMLHttpRequest();
xhr.open("POST", "/api/upload");
xhr.setRequestHeader("x-file-name", encodeURIComponent(file.name));
xhr.setRequestHeader("content-type", file.type || "application/octet-stream");
xhr.responseType = "json";
xhr.upload.onprogress = (e) => {
if (e.lengthComputable) onProgress(Math.round((e.loaded / e.total) * 100));
};
xhr.onload = () => {
if (xhr.status >= 200 && xhr.status < 300) resolve(xhr.response as Uploaded);
else reject(new Error((xhr.response as { error?: string } | null)?.error ?? `Upload failed (${xhr.status})`));
};
xhr.onerror = () => reject(new Error("Network error"));
xhr.onabort = () => reject(new DOMException("Upload cancelled", "AbortError"));
if (signal?.aborted) return reject(new DOMException("Upload cancelled", "AbortError"));
signal?.addEventListener("abort", () => xhr.abort(), { once: true });
xhr.send(file); // the raw file, which the route streams to disk
});
}
app/upload/actions.ts
"use server";
import { unlink } from "node:fs/promises";
import { join } from "node:path";
import { revalidatePath } from "next/cache";
import type { Uploaded } from "@/lib/upload-with-progress";
const DIR = join(process.cwd(), "uploads");
const SAFE_ID = /^[0-9a-f-]{36}-[\w.-]+$/;
// Called after the route handler has the bytes. Write the row in your database here.
export async function saveUpload(file: Uploaded): Promise<void> {
if (!SAFE_ID.test(file.id)) throw new Error("Bad file id");
// await db.files.insert({ id: file.id, name: file.name, size: file.size, userId })
revalidatePath("/upload");
}
// Removes a file the user uploaded. The id check keeps paths inside ./uploads.
export async function deleteUpload(id: string): Promise<{ error?: string }> {
if (!SAFE_ID.test(id)) return { error: "Bad file id" };
try {
await unlink(join(DIR, id));
} catch {
return { error: "That file is already gone." };
}
revalidatePath("/upload");
return {};
}
app/upload/upload-form.tsx
"use client";
import { useRef, useState } from "react";
import { uploadWithProgress, type Uploaded } from "@/lib/upload-with-progress";
import { saveUpload } from "./actions";
type State = { status: "idle" | "uploading" | "done" | "error"; pct: number; message?: string };
export function UploadForm() {
const [state, setState] = useState<State>({ status: "idle", pct: 0 });
const ctrl = useRef<AbortController | null>(null);
async function onFile(file: File) {
ctrl.current = new AbortController();
setState({ status: "uploading", pct: 0 });
try {
const up: Uploaded = await uploadWithProgress(file, (pct) => setState({ status: "uploading", pct }), ctrl.current.signal);
// the bytes are on the server; a server action records them with the user's session
await saveUpload(up);
setState({ status: "done", pct: 100, message: `${up.name} uploaded` });
} catch (err) {
const cancelled = err instanceof DOMException && err.name === "AbortError";
setState({ status: cancelled ? "idle" : "error", pct: 0, message: cancelled ? undefined : (err as Error).message });
}
}
return (
<div className="grid max-w-md gap-3">
<input type="file" disabled={state.status === "uploading"} onChange={(e) => e.target.files?.[0] && onFile(e.target.files[0])} />
{state.status === "uploading" && (
<div className="flex items-center gap-3">
<progress max={100} value={state.pct} className="w-full" aria-label="Upload progress" />
<span className="w-10 text-right text-sm tabular-nums">{state.pct}%</span>
<button type="button" onClick={() => ctrl.current?.abort()} className="text-sm underline">
Cancel
</button>
</div>
)}
{state.message ? <p role={state.status === "error" ? "alert" : "status"} className="text-sm">{state.message}</p> : null}
</div>
);
}