Every SaaS eventually needs file uploads, and the first instinct — POST the file to your own API route, then forward it to storage — is the wrong one. Your serverless function becomes a proxy for every byte, you hit body-size limits, and users on slow connections tie up your compute.
The better flow takes three steps:
- The browser asks your server for permission to upload a specific file.
- The server returns a short-lived presigned URL for exactly that object.
- The browser uploads directly to the bucket. Your server never touches the bytes.
R2 speaks the S3 API, so the standard AWS SDK works as-is.
Signing on the server
import { S3Client, PutObjectCommand } from "@aws-sdk/client-s3";
import { getSignedUrl } from "@aws-sdk/s3-request-presigner";
import { NextResponse } from "next/server";
const r2 = new S3Client({
region: "auto",
endpoint: `https://${process.env.R2_ACCOUNT_ID}.r2.cloudflarestorage.com`,
credentials: {
accessKeyId: process.env.R2_ACCESS_KEY_ID!,
secretAccessKey: process.env.R2_SECRET_ACCESS_KEY!,
},
});
const ALLOWED_TYPES = ["application/pdf", "image/png", "image/jpeg"];
const MAX_SIZE = 10 * 1024 * 1024; // 10 MB
export async function POST(req: Request) {
const { filename, contentType, size } = await req.json();
if (!ALLOWED_TYPES.includes(contentType) || size > MAX_SIZE) {
return NextResponse.json({ error: "File not allowed" }, { status: 400 });
}
const key = `uploads/${crypto.randomUUID()}-${filename}`;
const url = await getSignedUrl(
r2,
new PutObjectCommand({
Bucket: process.env.R2_BUCKET!,
Key: key,
ContentType: contentType,
}),
{ expiresIn: 300 }, // 5 minutes
);
return NextResponse.json({ url, key });
}Two non-obvious decisions in there:
- Generate the key server-side. Never let the client choose object keys, or one user can overwrite another's files.
- Validate before signing. The signature locks in the content type; pair it with a size check here and (belt-and-braces) a bucket lifecycle rule for anything oversized that sneaks through.
Uploading from the browser
export async function uploadFile(file: File): Promise<string> {
const res = await fetch("/api/upload-url", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
filename: file.name,
contentType: file.type,
size: file.size,
}),
});
if (!res.ok) throw new Error("Could not get upload URL");
const { url, key } = await res.json();
const put = await fetch(url, {
method: "PUT",
headers: { "Content-Type": file.type },
body: file,
});
if (!put.ok) throw new Error("Upload failed");
return key; // store this in your database
}The Content-Type header on the PUT must match what you signed — mismatches are the most common cause of mysterious 403s.
Don't forget CORS
The browser is now talking to R2 directly, so the bucket needs a CORS policy:
[
{
"AllowedOrigins": ["https://yourapp.com"],
"AllowedMethods": ["PUT"],
"AllowedHeaders": ["Content-Type"],
"MaxAgeSeconds": 3600
}
]Lock AllowedOrigins to your production domain (plus localhost in dev). A wildcard here quietly turns your signed URLs into a public upload endpoint for any site that obtains one.
What you get
With ~80 lines of code: no body-size limits, no egress through your functions, files land in a private bucket, and every upload is authorised individually with a five-minute window. The same pattern works for downloads — sign a GetObjectCommand instead — which keeps the bucket private end to end.