File upload cheatsheet.
Form action upload
"use server";
import fs from "node:fs/promises";
export async function upload(formData: FormData) {
const file = formData.get("file") as File;
const buf = Buffer.from(await file.arrayBuffer());
await fs.writeFile(`./public/uploads/${file.name}`, buf);
}
<form action={upload} encType="multipart/form-data">
<input type="file" name="file" required />
<button>Upload</button>
</form>
Works without JS. Limited by request size (Vercel: 4MB body limit on serverless funcs).
Route handler upload
// app/api/upload/route.ts
import { NextResponse } from "next/server";
import { writeFile } from "node:fs/promises";
export async function POST(req: Request) {
const fd = await req.formData();
const file = fd.get("file") as File;
if (!file) return NextResponse.json({ error: "no file" }, { status: 400 });
if (file.size > 5 * 1024 * 1024) return NextResponse.json({ error: "too large" }, { status: 413 });
const buf = Buffer.from(await file.arrayBuffer());
await writeFile(`./public/uploads/${file.name}`, buf);
return NextResponse.json({ ok: true, name: file.name });
}
Client upload component
"use client";
import { useState } from "react";
export function Upload() {
const [progress, setProgress] = useState(0);
async function onChange(e: React.ChangeEvent<HTMLInputElement>) {
const file = e.target.files?.[0];
if (!file) return;
const fd = new FormData();
fd.append("file", file);
const xhr = new XMLHttpRequest();
xhr.upload.onprogress = (e) => setProgress((e.loaded / e.total) * 100);
xhr.open("POST", "/api/upload");
xhr.send(fd);
}
return (
<>
<input type="file" onChange={onChange} />
{progress > 0 && <progress value={progress} max="100" />}
</>
);
}
Presigned S3 upload (recommended for large files)
// app/api/presign/route.ts
import { S3Client, PutObjectCommand } from "@aws-sdk/client-s3";
import { getSignedUrl } from "@aws-sdk/s3-request-presigner";
const s3 = new S3Client({ region: "us-east-1" });
export async function POST(req: Request) {
const { filename, contentType } = await req.json();
const key = `uploads/${crypto.randomUUID()}-${filename}`;
const cmd = new PutObjectCommand({
Bucket: process.env.S3_BUCKET!,
Key: key,
ContentType: contentType,
});
const url = await getSignedUrl(s3, cmd, { expiresIn: 600 });
return Response.json({ url, key });
}
"use client";
async function upload(file: File) {
const { url, key } = await fetch("/api/presign", {
method: "POST",
body: JSON.stringify({ filename: file.name, contentType: file.type }),
}).then(r => r.json());
await fetch(url, { method: "PUT", body: file, headers: { "Content-Type": file.type } });
// Tell server "upload complete":
await fetch("/api/upload-done", { method: "POST", body: JSON.stringify({ key }) });
}
Bypasses Next.js, goes straight to S3. No body-size limit on your function.
Cloudflare R2
R2 is S3-compatible:
const s3 = new S3Client({
region: "auto",
endpoint: `https://${process.env.R2_ACCOUNT_ID}.r2.cloudflarestorage.com`,
credentials: { accessKeyId: ..., secretAccessKey: ... },
});
Cheaper than S3 (no egress fees).
UploadThing / Uploadcare / Vercel Blob
Hosted services for simpler integration:
npm i @vercel/blob
// app/api/upload/route.ts
import { put } from "@vercel/blob";
export async function POST(req: Request) {
const fd = await req.formData();
const file = fd.get("file") as File;
const blob = await put(file.name, file, { access: "public" });
return Response.json(blob);
}
Validating files
const ALLOWED = new Set(["image/jpeg", "image/png", "image/webp"]);
const MAX = 10 * 1024 * 1024;
function validate(file: File) {
if (!ALLOWED.has(file.type)) throw new Error("invalid type");
if (file.size > MAX) throw new Error("too large");
}
Don’t trust filename or extension; check actual content type. For images, parse the buffer to verify.
Image resize on upload
import sharp from "sharp";
const buf = Buffer.from(await file.arrayBuffer());
const resized = await sharp(buf).resize(800).webp().toBuffer();
await s3.send(new PutObjectCommand({ Bucket: ..., Key: key, Body: resized }));
Server-side: do compute on serverful host (not edge). Or process on demand via Image CDN.
Multipart (huge files)
For files > 5GB, S3 multipart upload:
import { CreateMultipartUploadCommand, UploadPartCommand, CompleteMultipartUploadCommand } from "@aws-sdk/client-s3";
const { UploadId } = await s3.send(new CreateMultipartUploadCommand({ Bucket, Key }));
// Upload parts (5MB+ each except last):
const parts = [];
for (let i = 0; i < chunks.length; i++) {
const { ETag } = await s3.send(new UploadPartCommand({
Bucket, Key, UploadId, PartNumber: i + 1, Body: chunks[i],
}));
parts.push({ PartNumber: i + 1, ETag });
}
await s3.send(new CompleteMultipartUploadCommand({
Bucket, Key, UploadId, MultipartUpload: { Parts: parts },
}));
Progress + resumable
For best UX, use tus
protocol (resumable uploads). Server: tus-node-server or hosted.
Common mistakes
- Trusting client-supplied content-type/filename.
- Storing in
public/(public, no auth, no CDN). - Direct DB insert with file binary → bloats DB.
- Upload via Next API for large files → hits body size limits.
- Forgetting
Content-Length/ signed URL expiry tuning.
Read this next
If you want my S3 + presigned upload starter, it’s at rajpoot.dev .
Building something AI-, backend-, or data-heavy and want a second pair of eyes? I do consulting and freelance work — see my projects and ways to reach me at rajpoot.dev .