Why We Ditched the 70MB AWS SDK for Native Web Crypto in Next.js 15 Edge Routes

typescript dev.to

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
Enter fullscreen mode Exit fullscreen mode

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:

  1. Massive Dependency Footprint: The modular @aws-sdk is 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).
  2. Cold Start Penalty: Larger bundles mean longer edge initialization times and sluggish cold starts.
  3. 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('');
}
Enter fullscreen mode Exit fullscreen mode

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}`;
}
Enter fullscreen mode Exit fullscreen mode

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",
  });
}
Enter fullscreen mode Exit fullscreen mode

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;
}
Enter fullscreen mode Exit fullscreen mode

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!

Source: dev.to

arrow_back Back to Tutorials