Building a Secure File Upload System with Next.js and Cloudflare R2: Presigned URLs Deep Dive
December 27, 2025
In this post, I'll walk through building a production file upload system using Next.js and Cloudflare R2. The journey started when I tried to upload a 50MB video through a Next.js API route and hit a wall of HTTP 413 errors and timeout issues. Understanding why this fails - and how presigned URLs solve it at the protocol level - took me down a rabbit hole of S3 signing algorithms and CORS configuration.
Here was my initial naive implementation - a Next.js API route that receives the file and forwards it to R2:
// pages/api/upload.ts
import type { NextApiRequest, NextApiResponse } from "next";
import { S3Client, PutObjectCommand } from "@aws-sdk/client-s3";
export const config = {
api: {
bodyParser: {
sizeLimit: "100mb", // Trying to increase the limit
},
},
};
export default async function handler(
req: NextApiRequest,
res: NextApiResponse,
) {
const file = req.body; // This is already wrong
// ... upload to R2
}
When I tried uploading a 50MB file, I got this error:
POST /api/upload 413 Payload Too Large
Even after setting sizeLimit: '100mb', the upload would timeout or crash the serverless function. The problem is architectural: Next.js API routes (especially on Vercel/Cloudflare) have platform limits that vary by plan and runtime:
- Vercel: Tight request size limits for serverless/edge functions (plan-dependent)
- Cloudflare Workers: Request size and buffering limits vary by runtime and configuration
- Next.js default: 1MB body parser limit
Even if you increase the limit, you're still routing the entire file through your server. For a 100MB file, that means:
- User uploads 100MB to your server
- Your server buffers 100MB in memory
- Your server uploads 100MB to R2
- Total bandwidth: 200MB, plus memory pressure on your server
The solution is presigned URLs - a way to let clients upload directly to R2, bypassing your server entirely. But to understand why this is secure, you need to understand how S3 request signing works.
How Presigned URLs Work
When you make a request to S3 (or R2, which is S3-compatible), the request must be authenticated. Normally, you'd include your credentials in the request headers:
Authorization: AWS4-HMAC-SHA256 Credential=AKIAIOSFODNN7EXAMPLE/20250116/auto/s3/aws4_request, SignedHeaders=host;x-amz-content-sha256;x-amz-date, Signature=fe5f80f77d5fa3beca038a248ff027d0445342fe2855ddc963176630326f1024
This signature is computed using HMAC-SHA256 over a "canonical request" that includes:
- HTTP method (PUT, GET, etc.)
- Path (/bucket/key)
- Query string
- Headers
- Payload hash
A presigned URL moves this signature into the query string instead of headers:
https://my-bucket.r2.cloudflarestorage.com/uploads/file.mp4?
X-Amz-Algorithm=AWS4-HMAC-SHA256&
X-Amz-Credential=AKIAIOSFODNN7EXAMPLE%2F20250116%2Fauto%2Fs3%2Faws4_request&
X-Amz-Date=20250116T120000Z&
X-Amz-Expires=3600&
X-Amz-SignedHeaders=host&
X-Amz-Signature=fe5f80f77d5fa3beca038a248ff027d0445342fe2855ddc963176630326f1024
The key insight is that X-Amz-Expires=3600 means this URL is only valid for 1 hour. After that, the signature is invalid and R2 rejects the request. This is why presigned URLs are secure - even if someone intercepts the URL, it expires quickly, and it only authorizes the specific operation (PUT to that specific key) that was signed.
Setting Up R2
First, I created an R2 bucket:
bunx wrangler r2 bucket create my-uploads
Output:
Creating bucket my-uploads...
✅ Created bucket my-uploads
R2 can be accessed via Worker bindings in Workers. But for presigned URLs from a Next.js API route (not running in a Worker), I needed API credentials:
# Create an API token with R2 read/write permissions
# Go to Cloudflare Dashboard → R2 → Manage R2 API Tokens
I stored these in .env.local:
R2_ACCOUNT_ID="a1b2c3d4e5f6..." # Your Cloudflare account ID
R2_ACCESS_KEY_ID="abc123..." # From API token creation
R2_SECRET_ACCESS_KEY="xyz789..." # From API token creation (shown once!)
R2_BUCKET_NAME="my-uploads"
R2_PUBLIC_URL="https://pub-abc123.r2.dev" # After enabling public access
Generating Presigned URLs
The AWS SDK handles the complex signing algorithm. Here's my API route:
// pages/api/upload-url.ts
import { S3Client, PutObjectCommand } from "@aws-sdk/client-s3";
import { getSignedUrl } from "@aws-sdk/s3-request-presigner";
import type { NextApiRequest, NextApiResponse } from "next";
import crypto from "crypto";
// R2 uses S3-compatible API, but the endpoint is different
const s3Client = new S3Client({
region: "auto", // R2 uses 'auto' region
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!,
},
});
// Allowed file types and their MIME types
const ALLOWED_TYPES: Record<string, string> = {
"image/jpeg": "jpg",
"image/png": "png",
"image/gif": "gif",
"image/webp": "webp",
"video/mp4": "mp4",
"video/webm": "webm",
"application/pdf": "pdf",
};
const MAX_FILE_SIZE = 100 * 1024 * 1024; // 100MB
export default async function handler(
req: NextApiRequest,
res: NextApiResponse,
) {
if (req.method !== "POST") {
return res.status(405).json({ error: "Method not allowed" });
}
const { filename, contentType, fileSize } = req.body;
// Validate content type
if (!ALLOWED_TYPES[contentType]) {
return res.status(400).json({
error: `Invalid file type. Allowed: ${Object.keys(ALLOWED_TYPES).join(", ")}`,
});
}
// Validate file size (client-reported, but we verify via Content-Length header)
if (fileSize > MAX_FILE_SIZE) {
return res.status(400).json({
error: `File too large. Maximum size: ${MAX_FILE_SIZE / 1024 / 1024}MB`,
});
}
// Generate unique key to prevent collisions and path traversal
const uuid = crypto.randomUUID();
const ext = ALLOWED_TYPES[contentType];
const key = `uploads/${uuid}.${ext}`;
// PutObjectCommand specifies what operation we're signing
const command = new PutObjectCommand({
Bucket: process.env.R2_BUCKET_NAME,
Key: key,
ContentType: contentType,
// Content-Length is not signed by default unless you include it as a signed header
// The client should still send a correct Content-Length header
});
try {
const uploadUrl = await getSignedUrl(s3Client, command, {
expiresIn: 3600, // 1 hour
// signableHeaders is important - it determines what headers
// are included in the signature
});
// Return both the presigned URL and the final public URL
res.status(200).json({
uploadUrl,
key,
publicUrl: `${process.env.R2_PUBLIC_URL}/${key}`,
expiresIn: 3600,
});
} catch (error) {
console.error("Presign error:", error);
res.status(500).json({ error: "Failed to generate upload URL" });
}
}
Let me break down what's happening in getSignedUrl:
-
Command inspection: It reads the
PutObjectCommandto know we want to sign a PUT request to/my-uploads/uploads/uuid.jpg -
Canonical request creation: Builds a string like:
PUT /my-uploads/uploads/abc123.jpg host:a1b2c3d4e5f6.r2.cloudflarestorage.com host UNSIGNED-PAYLOAD -
String to sign: Creates a string including the canonical request hash, timestamp, and credential scope
-
Signature computation: Uses HMAC-SHA256 with your secret key to sign the string
-
URL construction: Appends all the signing parameters to the URL
Client-Side Upload
Here's the React component that handles the upload:
'use client'
// components/FileUpload.tsx
import { useCallback, useState } from 'react'
interface UploadState {
status: 'idle' | 'getting-url' | 'uploading' | 'success' | 'error'
progress: number
error?: string
publicUrl?: string
}
export function FileUpload() {
const [file, setFile] = useState<File | null>(null)
const [state, setState] = useState<UploadState>({
status: 'idle',
progress: 0,
})
const handleUpload = useCallback(async () => {
if (!file) return
setState({ status: 'getting-url', progress: 0 })
try {
// Step 1: Request presigned URL from our API
const urlResponse = await fetch('/api/upload-url', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
filename: file.name,
contentType: file.type,
fileSize: file.size,
}),
})
if (!urlResponse.ok) {
const error = await urlResponse.json()
throw new Error(error.error || 'Failed to get upload URL')
}
const { uploadUrl, publicUrl } = await urlResponse.json()
setState({ status: 'uploading', progress: 0 })
// Step 2: Upload directly to R2 using XMLHttpRequest for progress
await new Promise<void>((resolve, reject) => {
const xhr = new XMLHttpRequest()
xhr.upload.addEventListener('progress', (e) => {
if (e.lengthComputable) {
const progress = Math.round((e.loaded / e.total) * 100)
setState((prev) => ({ ...prev, progress }))
}
})
xhr.addEventListener('load', () => {
if (xhr.status >= 200 && xhr.status < 300) {
resolve()
} else {
// Parse R2 error response (XML format)
reject(new Error(`Upload failed: ${xhr.status} ${xhr.statusText}`))
}
})
xhr.addEventListener('error', () => {
reject(new Error('Network error during upload'))
})
xhr.open('PUT', uploadUrl)
// CRITICAL: Content-Type must match what was signed
xhr.setRequestHeader('Content-Type', file.type)
xhr.send(file)
})
setState({ status: 'success', progress: 100, publicUrl })
} catch (error) {
setState({
status: 'error',
progress: 0,
error: error instanceof Error ? error.message : 'Upload failed',
})
}
}, [file])
return (
<div className="space-y-4">
<input
type="file"
onChange={(e) => setFile(e.target.files?.[0] || null)}
accept="image/*,video/*,.pdf"
className="block w-full"
/>
{file && (
<div className="text-sm text-gray-600">
{file.name} ({(file.size / 1024 / 1024).toFixed(2)} MB)
</div>
)}
<button
onClick={handleUpload}
disabled={!file || state.status === 'uploading'}
className="px-4 py-2 bg-blue-500 text-white rounded disabled:opacity-50"
>
{state.status === 'getting-url' && 'Preparing...'}
{state.status === 'uploading' && `Uploading ${state.progress}%`}
{state.status === 'idle' && 'Upload'}
{state.status === 'success' && 'Done!'}
{state.status === 'error' && 'Retry'}
</button>
{state.status === 'uploading' && (
<div className="w-full bg-gray-200 rounded-full h-2">
<div
className="bg-blue-500 h-2 rounded-full transition-all"
style={{ width: `${state.progress}%` }}
/>
</div>
)}
{state.status === 'error' && (
<div className="text-red-500 text-sm">{state.error}</div>
)}
{state.status === 'success' && state.publicUrl && (
<a
href={state.publicUrl}
target="_blank"
rel="noopener noreferrer"
className="text-blue-500 underline"
>
View uploaded file
</a>
)}
</div>
)
}
CORS Configuration
If you run this code, you'll immediately hit a CORS error:
Access to XMLHttpRequest at 'https://xxx.r2.cloudflarestorage.com/...'
from origin 'http://localhost:3000' has been blocked by CORS policy:
Response to preflight request doesn't pass access control check:
No 'Access-Control-Allow-Origin' header is present.
The browser sends a preflight OPTIONS request before the actual PUT, and R2 needs to respond with appropriate CORS headers. I created a CORS configuration file:
// cors-config.json
{
"CORSRules": [
{
"AllowedOrigins": ["http://localhost:3000", "https://myapp.com"],
"AllowedMethods": ["GET", "PUT", "HEAD"],
"AllowedHeaders": ["*"],
"ExposeHeaders": ["ETag"],
"MaxAgeSeconds": 3600
}
]
}
Applied it to the bucket:
bunx wrangler r2 bucket cors put my-uploads --file cors-config.json
Key CORS settings explained:
- AllowedOrigins: Which domains can make requests. Use specific origins in production, not
* - AllowedMethods:
PUTfor uploads,GETfor downloads,HEADfor checking existence - AllowedHeaders:
*allows Content-Type and other headers the client might send - ExposeHeaders:
ETagis returned by R2 after upload - useful for verification - MaxAgeSeconds: Browser caches preflight response for 1 hour
Common Errors and Debugging
Here are the errors I encountered and how I fixed them:
1. SignatureDoesNotMatch
<Error>
<Code>SignatureDoesNotMatch</Code>
<Message>The request signature we calculated does not match the signature you provided.</Message>
</Error>
This happens when the Content-Type header in the PUT request doesn't match what was signed. The presigned URL was generated for image/jpeg, but the client sent image/png. Fix: Ensure the client sends exactly the same Content-Type.
2. AccessDenied after expiry
<Error>
<Code>AccessDenied</Code>
<Message>Request has expired</Message>
<RequestTime>2025-01-16T12:00:00Z</RequestTime>
<ServerTime>2025-01-16T13:01:00Z</ServerTime>
</Error>
The presigned URL expired (1 hour passed). Fix: Generate a new URL before uploading.
3. EntityTooLarge
<Error>
<Code>EntityTooLarge</Code>
<Message>Your proposed upload exceeds the maximum allowed size</Message>
</Error>
R2 has a maximum object size and supports multipart uploads for larger files. This error usually means you hit a size limit or a bucket policy limit.
Making Files Public
For uploaded files to be accessible via URL, I enabled public access:
bunx wrangler r2 bucket sippy enable my-uploads --jurisdiction=default
This creates a public URL like https://pub-abc123.r2.dev. Alternatively, for custom domain:
# In Cloudflare Dashboard:
# R2 → my-uploads → Settings → Public Access → Custom Domain
The complete flow now looks like:
Total bandwidth through my server: ~1KB (JSON request/response). The 50MB file goes directly from user to R2. My server stays fast, and R2's zero egress fees help keep download costs low (though requests still incur costs). The tradeoff is added complexity - you need to handle CORS, URL expiry, and client-side error handling. But for any serious file upload system, presigned URLs are the way to go.