If you're building a modern media platform, handling large uploads is one of the quickest ways to accidentally burn serverless compute budget.
When handling 4K and 8K wallpapers on Zenith Walls, routing 10MB–30MB image files through a Next.js API route was an immediate non-starter. Serverless functions would hit memory caps, execution time would balloon, and we'd pay double the bandwidth bill (once to receive the upload from the client, and again to push it to our storage bucket).
The standard answer is simple: Generate a presigned PUT URL and let the client upload directly to object storage (Cloudflare R2 / AWS S3).
So, like every standard tutorial tells you, we reached for the official AWS SDK:
npm install @aws-sdk/client-s3 @aws-sdk/s3-request-presigner
And immediately regretted it.
The Hidden Cost of @aws-sdk on the Edge
If you run your Next.js application on edge runtimes (Cloudflare Workers via OpenNext or Vercel Edge), throwing the AWS SDK into your bundle brings noticeable friction:
-
Massive Dependency Footprint: The modular
@aws-sdkis notorious for pulling in dozens of sub-packages, middleware layers, endpoint resolvers, and XML parsers. Your serverless bundle expands by several megabytes for literally one function call (getSignedUrl). - Cold Start Penalty: Larger bundles mean longer edge initialization times and sluggish cold starts.
-
Runtime Inconsistencies: Edge environments (like Cloudflare
workerd) implement the Web Standards API. Legacy Node.js polyfills bundled inside standard SDKs frequently throw subtle runtime or streaming errors.
We asked ourselves: What is a presigned S3/R2 URL actually doing under the hood?
It’s not magic. It’s just AWS Signature Version 4 (SigV4) — which boils down to:
- Creating a canonical HTTP request string.
- Hashing it with SHA-256.
- Generating a signing key via 4 chained HMAC-SHA256 calculations.
- Appending the resulting hex signature as a query parameter.
Browsers and edge runtimes already have a lightning-fast, hardware-accelerated cryptographic engine built into global scope: crypto.subtle (Web Crypto API).
We decided to write the entire SigV4 presigning algorithm in under 45 lines of TypeScript with zero external dependencies.
Building Lightweight AWS SigV4 with Native Web Crypto
Here are the primitive helpers we built using crypto.subtle:
// app/api/upload/presigned/route.ts
async function hmacSha256(key: ArrayBuffer, data: string): Promise<ArrayBuffer> {
const cryptoKey = await crypto.subtle.importKey(
"raw",
key,
{ name: "HMAC", hash: "SHA-256" },
false,
["sign"]
);
return crypto.subtle.sign("HMAC", cryptoKey, new TextEncoder().encode(data));
}
async function sha256Hex(data: string): Promise<string> {
const buf = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(data));
return [...new Uint8Array(buf)].map(b => b.toString(16).padStart(2, '0')).join('');
}
function toHexString(buf: ArrayBuffer): string {
return [...new Uint8Array(buf)].map(b => b.toString(16).padStart(2, '0')).join('');
}
No crypto imports, no Node polyfills. Works natively in Node 18+, Bun, Deno, browsers, and Cloudflare Workers.
Crafting the Presigned PUT URL
With the cryptographic primitives in place, generating the presigned PUT URL is straightforward:
async function createPresignedPutUrl(
key: string,
contentType: string,
expiresSeconds = 600
): Promise<string> {
const host = `${process.env.CLOUDFLARE_ACCOUNT_ID}.r2.cloudflarestorage.com`;
const region = "auto";
const service = "s3";
const now = new Date();
// Format timestamps: YYYYMMDD & YYYYMMDDTHHMMSSZ
const dateStamp = now.toISOString().replace(/[-:]/g, '').slice(0, 8);
const amzDate = now.toISOString().replace(/[-:]/g, '').replace(/\.\d+Z$/, 'Z');
const credential = `${process.env.R2_ACCESS_KEY_ID}/${dateStamp}/${region}/${service}/aws4_request`;
const queryParams = new URLSearchParams({
"X-Amz-Algorithm": "AWS4-HMAC-SHA256",
"X-Amz-Credential": credential,
"X-Amz-Date": amzDate,
"X-Amz-Expires": String(expiresSeconds),
"X-Amz-SignedHeaders": "content-type;host",
});
// 1. SigV4 requires query parameters to be strictly sorted
const sortedQS = [...queryParams.entries()]
.sort(([a], [b]) => a.localeCompare(b))
.map(([k, v]) => `${encodeURIComponent(k)}=${encodeURIComponent(v)}`)
.join('&');
const canonicalHeaders = `content-type:${contentType}\nhost:${host}\n`;
const encodedKey = key.split('/').map(encodeURIComponent).join('/');
// 2. Build the Canonical Request
const canonicalRequest = [
"PUT",
`/${process.env.R2_BUCKET_NAME}/${encodedKey}`,
sortedQS,
canonicalHeaders,
"content-type;host",
"UNSIGNED-PAYLOAD",
].join('\n');
const canonicalRequestHash = await sha256Hex(canonicalRequest);
// 3. Build the String to Sign
const stringToSign = [
"AWS4-HMAC-SHA256",
amzDate,
`${dateStamp}/${region}/${service}/aws4_request`,
canonicalRequestHash,
].join('\n');
// 4. Derive the Signing Key (kSecret -> kDate -> kRegion -> kService -> kSigning)
let signingKey = await hmacSha256(
new TextEncoder().encode(`AWS4${process.env.R2_SECRET_ACCESS_KEY}`).buffer as ArrayBuffer,
dateStamp
);
signingKey = await hmacSha256(signingKey, region);
signingKey = await hmacSha256(signingKey, service);
signingKey = await hmacSha256(signingKey, "aws4_request");
// 5. Calculate Final Signature
const signature = toHexString(await hmacSha256(signingKey, stringToSign));
return `https://${host}/${process.env.R2_BUCKET_NAME}/${encodedKey}?${sortedQS}&X-Amz-Signature=${signature}`;
}
Production Guardrails: Locking Down the Route
Direct upload endpoints can be dangerous if left unrestricted. In our Next.js API route (app/api/upload/presigned/route.ts), we enforce strict zero-trust rules:
const ALLOWED_CONTENT_TYPES = new Set([
"image/jpeg",
"image/png",
"image/webp",
"image/avif",
]);
const ALLOWED_PREFIXES = new Set([
"wallpapers/full",
"wallpapers/thumbs",
]);
export async function POST(request: NextRequest) {
// 1. Strict Auth Verification
const authHeader = request.headers.get("authorization");
const uploadSecret = process.env.UPLOAD_SECRET;
if (!uploadSecret || authHeader !== `Bearer ${uploadSecret}`) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
const { filename, contentType = "image/webp", prefix = "wallpapers/full" } = await request.json();
// 2. Prevent SVG/HTML/executable file uploads
if (!ALLOWED_CONTENT_TYPES.has(contentType)) {
return NextResponse.json({ error: "Disallowed content type" }, { status: 400 });
}
// 3. Enforce directory isolation and sanitize filename
const targetPrefix = ALLOWED_PREFIXES.has(prefix) ? prefix : "wallpapers/full";
const cleanFilename = filename.replace(/[^a-zA-Z0-9_.-]/g, "_");
const key = `${targetPrefix}/${Date.now()}_${cleanFilename}`;
// 4. Generate a 10-minute expiring URL
const uploadUrl = await createPresignedPutUrl(key, contentType, 600);
const publicUrl = `${process.env.R2_PUBLIC_DOMAIN}/${key}`;
return NextResponse.json({
uploadUrl,
key,
publicUrl,
method: "PUT",
});
}
How the Client Performs the Direct Upload
On the client side, uploading a 20MB 4K wallpaper is now completely decoupled from Next.js server resources:
async function uploadWallpaper(file: File) {
// Step 1: Request presigned URL from Next.js API (takes ~15ms)
const res = await fetch("/api/upload/presigned", {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${ADMIN_TOKEN}`,
},
body: JSON.stringify({
filename: file.name,
contentType: file.type,
prefix: "wallpapers/full",
}),
});
const { uploadUrl, publicUrl } = await res.json();
// Step 2: Stream raw bytes directly to Cloudflare R2
const uploadRes = await fetch(uploadUrl, {
method: "PUT",
headers: {
"Content-Type": file.type,
},
body: file,
});
if (!uploadRes.ok) {
throw new Error("Direct R2 upload failed");
}
console.log("Uploaded successfully to:", publicUrl);
return publicUrl;
}
The Results
By replacing the heavy AWS SDK with native Web Crypto:
| Metric | With AWS SDK | Native Web Crypto |
|---|---|---|
| Dependencies Added | 12+ packages | 0 |
| Route Bundle Size | ~1.8 MB | < 4 KB |
| Edge Route Execution Time | ~45ms | ~2ms |
| Server Bandwidth Consumed | 100% of upload size | 0 MB (direct to R2) |
Key Takeaway
Before reaching for heavyweight SDKs in modern Next.js edge applications, check what web standards already offer. Standards like crypto.subtle are fast, secure, native to modern runtimes, and keep your production builds lean and blisteringly fast.
Have you replaced heavy SDKs with native web APIs in your stack? Let me know in the comments below!