Skip to content
portfolioblogpresigned-uploads-r2-nextjs
cd ../blog

2 Apr 2026 · 2 min read

Direct-to-R2 file uploads in Next.js with presigned URLs

Next.jsCloudflare R2TypeScript

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:

  1. The browser asks your server for permission to upload a specific file.
  2. The server returns a short-lived presigned URL for exactly that object.
  3. 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

app/api/upload-url/route.ts
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

lib/upload.ts
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:

r2-cors.json
[
  {
    "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.

// thanks for reading

Questions, corrections or war stories of your own? Email me — I read everything.