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" />}
    </>
  );
}
// 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 .