<?xml version="1.0" encoding="utf-8"?>
<rss version="2.0">
<channel>
<title>Coder Legion - Recent posts tagged multipart-upload</title>
<link>https://coderlegion.com/tag/multipart-upload</link>
<description>Powered by Question2Answer</description>
<item>
<title>Next.js 16 AWS S3 File Upload Guide: Presigned URLs, Proxy &amp; Multipart</title>
<link>https://coderlegion.com/23910/next-js-16-aws-s3-file-upload-guide-presigned-urls-proxy-multipart</link>
<description>&lt;p&gt;At some point, almost every real app needs file uploads — profile pictures, PDFs, videos, CSV imports. The tempting move is to just &lt;code&gt;POST&lt;/code&gt; the file straight to your Next.js server and save it somewhere. That works great in a tutorial and falls apart the moment a real user uploads a 200MB video, because your serverless function times out, hits its body size limit, or just burns money streaming bytes it didn't need to touch.&lt;/p&gt;
&lt;p&gt;The fix is almost always &lt;strong&gt;Amazon S3&lt;/strong&gt; (Simple Storage Service) — a place built specifically to store files cheaply and reliably, so your Next.js app doesn't have to. But &quot;just use S3&quot; isn't one pattern, it's three, and picking the wrong one is why most S3 upload tutorials online feel incomplete or break in production.&lt;/p&gt;
&lt;p&gt;In this guide, you'll build all three real-world upload patterns in Next.js 16:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;Server-side proxying&lt;/strong&gt; — the file passes through your Next.js server on its way to S3&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Direct-to-S3 presigned URLs&lt;/strong&gt; — the browser uploads straight to S3, skipping your server entirely&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Multipart uploads&lt;/strong&gt; — for large files, split into chunks so they upload reliably and can resume&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;By the end, you'll know exactly which pattern to reach for and have working code for each.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;why-this-is-tricky&quot;&gt;Why This Is Tricky&lt;/h3&gt;
&lt;p&gt;Here's the core tension: your Next.js server (especially on serverless platforms like Vercel) is not a good place for large files to pass through.&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Body size limits&lt;/strong&gt; — most hosting platforms cap how much data a single request can carry (Vercel's default is 4.5MB for serverless functions).&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Function timeouts&lt;/strong&gt; — a slow upload over a weak connection can outlive your function's execution limit.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;You pay for time, not just requests&lt;/strong&gt; — every second your function spends waiting on a file transfer is a second you're billed for, even though your server isn't doing any real work.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;So the real skill here isn't &quot;how do I upload to S3&quot; — it's &quot;how much of this upload actually needs to touch my server?&quot; That question is what decides which of the three patterns you reach for.&lt;/p&gt;
&lt;blockquote&gt;&lt;p&gt;💡 &lt;strong&gt;Tip:&lt;/strong&gt; As a rule of thumb — small files (avatars, documents under a few MB) are fine to proxy through your server. Anything user-facing and potentially large (videos, zip files, high-res images) should go direct-to-S3.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;hr&gt;
&lt;h3 id=&quot;prerequisites-amp-setup&quot;&gt;Prerequisites &amp;amp; Setup&lt;/h3&gt;
&lt;p&gt;This guide assumes:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Next.js 16&lt;/strong&gt; with the App Router&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;TypeScript&lt;/strong&gt; in strict mode&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Tailwind CSS&lt;/strong&gt; for the (minimal) UI examples&lt;/li&gt;
&lt;li&gt;An &lt;strong&gt;AWS account&lt;/strong&gt; with an S3 bucket&lt;/li&gt;
&lt;/ul&gt;
&lt;h4 id=&quot;step-1-create-an-s3-bucket&quot;&gt;Step 1: Create an S3 Bucket&lt;/h4&gt;
&lt;p&gt;In the &lt;a rel=&quot;nofollow&quot; href=&quot;https://console.aws.amazon.com/s3/&quot;&gt;AWS Console&lt;/a&gt;:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;Click &lt;strong&gt;Create bucket&lt;/strong&gt;&lt;/li&gt;
&lt;li&gt;Give it a globally unique name, e.g. &lt;code&gt;devstacked-uploads-demo&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;Pick a region close to your users&lt;/li&gt;
&lt;li&gt;Leave &lt;strong&gt;Block all public access&lt;/strong&gt; checked — you don't need a public bucket for any of these three patterns&lt;/li&gt;
&lt;/ol&gt;
&lt;blockquote&gt;&lt;p&gt;⚠️ &lt;strong&gt;Common Mistake:&lt;/strong&gt; Making the bucket public &quot;to make uploads easier.&quot; None of the patterns in this guide need a public bucket. Presigned URLs grant temporary, scoped access without ever exposing the bucket itself.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;h4 id=&quot;step-2-create-an-iam-user-for-programmatic-access&quot;&gt;Step 2: Create an IAM User for Programmatic Access&lt;/h4&gt;
&lt;p&gt;Your Next.js server needs credentials to talk to S3.&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;Go to &lt;strong&gt;IAM → Users → Create user&lt;/strong&gt;&lt;/li&gt;
&lt;li&gt;Name it something like &lt;code&gt;nextjs-s3-uploader&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;Attach a policy scoped to just this bucket — not full &lt;code&gt;AmazonS3FullAccess&lt;/code&gt;:&lt;/li&gt;
&lt;/ol&gt;
&lt;pre&gt;&lt;code class=&quot;language-json&quot;&gt;// s3-upload-policy.json
{
  &quot;Version&quot;: &quot;2012-10-17&quot;,
  &quot;Statement&quot;: [
    {
      &quot;Effect&quot;: &quot;Allow&quot;,
      &quot;Action&quot;: [
        &quot;s3:PutObject&quot;,
        &quot;s3:GetObject&quot;,
        &quot;s3:AbortMultipartUpload&quot;,
        &quot;s3:ListMultipartUploadParts&quot;
      ],
      &quot;Resource&quot;: &quot;arn:aws:s3:::devstacked-uploads-demo/*&quot;
    }
  ]
}
&lt;/code&gt;&lt;/pre&gt;
&lt;ol&gt;
&lt;li&gt;After creating the user, open it and generate an &lt;strong&gt;Access key&lt;/strong&gt;&lt;/li&gt;
&lt;/ol&gt;
&lt;blockquote&gt;&lt;p&gt;⚠️ &lt;strong&gt;Common Mistake:&lt;/strong&gt; Using your AWS root account credentials in &lt;code&gt;.env.local&lt;/code&gt;. Always create a scoped IAM user — if those keys ever leak, the blast radius is one bucket, not your entire AWS account.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;h4 id=&quot;step-3-configure-cors-on-the-bucket&quot;&gt;Step 3: Configure CORS on the Bucket&lt;/h4&gt;
&lt;p&gt;Two of the three patterns in this guide (presigned URLs and multipart) have the &lt;strong&gt;browser&lt;/strong&gt; talk directly to S3. By default, S3 blocks cross-origin requests from your site, so you need to explicitly allow it.&lt;/p&gt;
&lt;p&gt;In your bucket → &lt;strong&gt;Permissions → Cross-origin resource sharing (CORS)&lt;/strong&gt;:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-json&quot;&gt;// s3-cors-config.json
[
  {
    &quot;AllowedHeaders&quot;: [&quot;*&quot;],
    &quot;AllowedMethods&quot;: [&quot;PUT&quot;, &quot;POST&quot;, &quot;GET&quot;],
    &quot;AllowedOrigins&quot;: [&quot;http://localhost:3000&quot;, &quot;https://yourdomain.com&quot;],
    &quot;ExposeHeaders&quot;: [&quot;ETag&quot;]
  }
]
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;Why &lt;code&gt;ExposeHeaders: [&quot;ETag&quot;]&lt;/code&gt; matters:&lt;/strong&gt; the multipart pattern later needs to read the &lt;code&gt;ETag&lt;/code&gt; header back from S3 after each chunk uploads. Without exposing it, your browser can see the response succeeded but can't read the header it needs.&lt;/p&gt;
&lt;blockquote&gt;&lt;p&gt;💡 &lt;strong&gt;Tip:&lt;/strong&gt; Consider enabling S3 Versioning from &lt;code&gt;Properties Tab&lt;/code&gt; if users may overwrite files. It provides recovery from accidental deletes or replacements.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;h4 id=&quot;step-4-install-dependencies&quot;&gt;Step 4: Install Dependencies&lt;/h4&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;npm install @aws-sdk/client-s3 @aws-sdk/s3-request-presigner
&lt;/code&gt;&lt;/pre&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;@aws-sdk/client-s3&lt;/code&gt; — the official AWS SDK v3 client for talking to S3&lt;/li&gt;
&lt;li&gt;&lt;code&gt;@aws-sdk/s3-request-presigner&lt;/code&gt; — generates temporary, signed upload URLs&lt;/li&gt;
&lt;/ul&gt;
&lt;h4 id=&quot;step-5-environment-variables&quot;&gt;Step 5: Environment Variables&lt;/h4&gt;
&lt;pre&gt;&lt;code class=&quot;language-env&quot;&gt;# .env.local
AWS_REGION=us-east-1
AWS_ACCESS_KEY_ID=your_access_key_id
AWS_SECRET_ACCESS_KEY=your_secret_access_key
S3_BUCKET_NAME=devstacked-uploads-demo
&lt;/code&gt;&lt;/pre&gt;
&lt;blockquote&gt;&lt;p&gt;⚠️ &lt;strong&gt;Common Mistake:&lt;/strong&gt; Never prefix these with &lt;code&gt;NEXT_PUBLIC_&lt;/code&gt;. That would ship your AWS secret key to every visitor's browser. These stay server-only, always.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;h4 id=&quot;step-6-create-a-reusable-s3-client&quot;&gt;Step 6: Create a Reusable S3 Client&lt;/h4&gt;
&lt;pre&gt;&lt;code class=&quot;language-ts&quot;&gt;// lib/s3-client.ts
import { S3Client } from &quot;@aws-sdk/client-s3&quot;;

export const s3Client = new S3Client({
  region: process.env.AWS_REGION!,
  credentials: {
    accessKeyId: process.env.AWS_ACCESS_KEY_ID!,
    secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY!,
  },
});
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Every pattern below imports this single client instead of creating a new one per request.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;pattern-1-server-side-proxying&quot;&gt;Pattern 1: Server-Side Proxying&lt;/h3&gt;
&lt;p&gt;&lt;strong&gt;How it works:&lt;/strong&gt; the browser sends the file to your Next.js server, and your server forwards it to S3. Your server sits in the middle of every byte.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-txt&quot;&gt;Browser → Next.js Route Handler → S3
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;This is the simplest pattern to reason about and the easiest to add validation, virus scanning, or image processing to — because your server actually sees the file. The tradeoff is exactly what we covered above: your server pays the time and bandwidth cost of the transfer, so this pattern is best kept for &lt;strong&gt;small files only&lt;/strong&gt; (a few MB at most).&lt;/p&gt;
&lt;h4 id=&quot;step-1-create-the-upload-route-handler&quot;&gt;Step 1: Create the Upload Route Handler&lt;/h4&gt;
&lt;pre&gt;&lt;code class=&quot;language-ts&quot;&gt;// app/api/upload/route.ts
import { NextRequest, NextResponse } from &quot;next/server&quot;;
import { PutObjectCommand } from &quot;@aws-sdk/client-s3&quot;;
import { s3Client } from &quot;@/lib/s3-client&quot;;
import { randomUUID } from &quot;crypto&quot;;

const MAX_FILE_SIZE = 5 * 1024 * 1024; // 5MB
const ALLOWED_TYPES = [&quot;image/jpeg&quot;, &quot;image/png&quot;, &quot;image/webp&quot;];

export async function POST(request: NextRequest) {
  try {
    const formData = await request.formData();
    const file = formData.get(&quot;file&quot;) as File | null;

    if (!file) {
      return NextResponse.json({ error: &quot;No file provided&quot; }, { status: 400 });
    }

    if (!ALLOWED_TYPES.includes(file.type)) {
      return NextResponse.json({ error: &quot;Unsupported file type&quot; }, { status: 400 });
    }

    if (file.size &amp;gt; MAX_FILE_SIZE) {
      return NextResponse.json({ error: &quot;File too large&quot; }, { status: 400 });
    }

    const buffer = Buffer.from(await file.arrayBuffer());
    const key = `avatars/${randomUUID()}-${file.name}`;

    await s3Client.send(
      new PutObjectCommand({
        Bucket: process.env.S3_BUCKET_NAME!,
        Key: key,
        Body: buffer,
        ContentType: file.type,
      })
    );

    return NextResponse.json({ key }, { status: 200 });
  } catch (error) {
    console.error(&quot;Upload error:&quot;, error);
    return NextResponse.json({ error: &quot;Upload failed&quot; }, { status: 500 });
  }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;What's happening here:&lt;/strong&gt;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;request.formData()&lt;/code&gt; reads the incoming multipart form data — this is how browsers send files&lt;/li&gt;
&lt;li&gt;We validate &lt;code&gt;file.type&lt;/code&gt; and &lt;code&gt;file.size&lt;/code&gt; &lt;strong&gt;before&lt;/strong&gt; touching S3, so bad requests fail fast and cheap&lt;/li&gt;
&lt;li&gt;&lt;code&gt;file.arrayBuffer()&lt;/code&gt; converts the browser &lt;code&gt;File&lt;/code&gt; object into a &lt;code&gt;Buffer&lt;/code&gt; that the AWS SDK can send&lt;/li&gt;
&lt;li&gt;The S3 object &lt;code&gt;Key&lt;/code&gt; (its path inside the bucket) includes a random UUID so two people uploading &lt;code&gt;photo.jpg&lt;/code&gt; never overwrite each other&lt;/li&gt;
&lt;/ul&gt;
&lt;blockquote&gt;&lt;p&gt;⚠️ &lt;strong&gt;Common Mistake:&lt;/strong&gt; Trusting &lt;code&gt;file.type&lt;/code&gt; alone for security. It's sent by the browser and can be spoofed. For anything sensitive, also validate the actual file signature (magic bytes) server-side, or run the file through a scanning service before it's considered &quot;safe.&quot;&lt;/p&gt;
&lt;/blockquote&gt;
&lt;h4 id=&quot;step-2-build-the-upload-form&quot;&gt;Step 2: Build the Upload Form&lt;/h4&gt;
&lt;pre&gt;&lt;code class=&quot;language-tsx&quot;&gt;// components/proxy-upload-form.tsx
&quot;use client&quot;;

import { useState } from &quot;react&quot;;

export function ProxyUploadForm() {
  const [uploading, setUploading] = useState(false);
  const [message, setMessage] = useState(&quot;&quot;);

  const handleSubmit = async (e: React.FormEvent&amp;lt;HTMLFormElement&amp;gt;) =&amp;gt; {
    e.preventDefault();
    const form = e.currentTarget;
    const fileInput = form.elements.namedItem(&quot;file&quot;) as HTMLInputElement;
    const file = fileInput.files?.[0];

    if (!file) return;

    setUploading(true);
    setMessage(&quot;&quot;);

    const formData = new FormData();
    formData.append(&quot;file&quot;, file);

    const res = await fetch(&quot;/api/upload&quot;, {
      method: &quot;POST&quot;,
      body: formData,
    });

    const data = await res.json();
    setUploading(false);
    setMessage(res.ok ? `Uploaded: ${data.key}` : data.error);
  };

  return (
    &amp;lt;form onSubmit={handleSubmit} className=&quot;flex flex-col gap-3 max-w-sm&quot;&amp;gt;
      &amp;lt;input type=&quot;file&quot; name=&quot;file&quot; accept=&quot;image/*&quot; required /&amp;gt;
      &amp;lt;button
        type=&quot;submit&quot;
        disabled={uploading}
        className=&quot;rounded-lg bg-primary px-4 py-2 text-white disabled:opacity-50&quot;
      &amp;gt;
        {uploading ? &quot;Uploading...&quot; : &quot;Upload&quot;}
      &amp;lt;/button&amp;gt;
      {message &amp;amp;&amp;amp; &amp;lt;p className=&quot;text-sm text-muted-foreground&quot;&amp;gt;{message}&amp;lt;/p&amp;gt;}
    &amp;lt;/form&amp;gt;
  );
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Nothing exotic here — a standard &lt;code&gt;FormData&lt;/code&gt; submission to our route handler. This pattern is genuinely the easiest to get right, which is exactly why it's the wrong choice once files get bigger.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;pattern-2-direct-to-s3-presigned-urls&quot;&gt;Pattern 2: Direct-to-S3 Presigned URLs&lt;/h3&gt;
&lt;p&gt;&lt;strong&gt;How it works:&lt;/strong&gt; your server never touches the file. Instead, it asks S3 for a &lt;strong&gt;presigned URL&lt;/strong&gt; — a temporary, signed link that grants permission to upload one specific file, to one specific location, for a short window of time. The browser then uploads directly to S3 using that URL.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-txt&quot;&gt;Browser → Next.js (ask for permission) → gets presigned URL
Browser → S3 directly (actual file upload)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Think of a presigned URL like a &lt;strong&gt;guest pass&lt;/strong&gt;: your server is the front desk that checks you're allowed in, then hands you a pass that only works for one door, for the next few minutes. You still never got a master key to the building.&lt;/p&gt;
&lt;p&gt;This is the pattern you want for anything user-facing that could be more than a few MB — profile videos, portfolio images, document uploads. Your server's job shrinks down to a single, fast decision: &quot;yes, generate a URL&quot; or &quot;no.&quot;&lt;/p&gt;
&lt;h4 id=&quot;step-1-generate-the-presigned-url&quot;&gt;Step 1: Generate the Presigned URL&lt;/h4&gt;
&lt;pre&gt;&lt;code class=&quot;language-ts&quot;&gt;// app/api/presigned-url/route.ts
import { NextRequest, NextResponse } from &quot;next/server&quot;;
import { PutObjectCommand } from &quot;@aws-sdk/client-s3&quot;;
import { getSignedUrl } from &quot;@aws-sdk/s3-request-presigner&quot;;
import { s3Client } from &quot;@/lib/s3-client&quot;;
import { randomUUID } from &quot;crypto&quot;;

const ALLOWED_TYPES = [&quot;image/jpeg&quot;, &quot;image/png&quot;, &quot;video/mp4&quot;];
const URL_EXPIRY_SECONDS = 60; // presigned URL is valid for 60 seconds

export async function POST(request: NextRequest) {
  try {
    const { fileName, fileType } = await request.json();

    if (!fileName || !fileType) {
      return NextResponse.json({ error: &quot;Missing fileName or fileType&quot; }, { status: 400 });
    }

    if (!ALLOWED_TYPES.includes(fileType)) {
      return NextResponse.json({ error: &quot;Unsupported file type&quot; }, { status: 400 });
    }

    const key = `uploads/${randomUUID()}-${fileName}`;

    const command = new PutObjectCommand({
      Bucket: process.env.S3_BUCKET_NAME!,
      Key: key,
      ContentType: fileType,
    });

    const uploadUrl = await getSignedUrl(s3Client, command, {
      expiresIn: URL_EXPIRY_SECONDS,
    });

    return NextResponse.json({ uploadUrl, key });
  } catch (error) {
    console.error(&quot;Presign error:&quot;, error);
    return NextResponse.json({ error: &quot;Could not generate upload URL&quot; }, { status: 500 });
  }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;What's happening here:&lt;/strong&gt;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;We never receive the actual file — just its &lt;code&gt;fileName&lt;/code&gt; and &lt;code&gt;fileType&lt;/code&gt;, which is why this endpoint responds instantly no matter how big the file will be&lt;/li&gt;
&lt;li&gt;&lt;code&gt;getSignedUrl()&lt;/code&gt; doesn't make a network call to AWS — it cryptographically signs the request locally using your IAM credentials, so it's fast&lt;/li&gt;
&lt;li&gt;&lt;code&gt;expiresIn: 60&lt;/code&gt; means this exact URL stops working after 60 seconds — plenty of time for the browser to start the upload, but not so long that a leaked URL stays exploitable&lt;/li&gt;
&lt;/ul&gt;
&lt;blockquote&gt;&lt;p&gt;💡 &lt;strong&gt;Tip:&lt;/strong&gt; Still validate &lt;code&gt;fileType&lt;/code&gt; here even though you're not seeing the actual bytes. It's your only real gate at this stage — S3 will happily accept whatever &lt;code&gt;ContentType&lt;/code&gt; the presigned request specifies.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;h4 id=&quot;step-2-upload-directly-from-the-browser&quot;&gt;Step 2: Upload Directly From the Browser&lt;/h4&gt;
&lt;pre&gt;&lt;code class=&quot;language-tsx&quot;&gt;// components/presigned-upload-form.tsx
&quot;use client&quot;;

import { useState } from &quot;react&quot;;

export function PresignedUploadForm() {
  const [progress, setProgress] = useState(0);
  const [status, setStatus] = useState&amp;lt;&quot;idle&quot; | &quot;uploading&quot; | &quot;done&quot; | &quot;error&quot;&amp;gt;(&quot;idle&quot;);

  const handleSubmit = async (e: React.FormEvent&amp;lt;HTMLFormElement&amp;gt;) =&amp;gt; {
    e.preventDefault();
    const form = e.currentTarget;
    const fileInput = form.elements.namedItem(&quot;file&quot;) as HTMLInputElement;
    const file = fileInput.files?.[0];
    if (!file) return;

    setStatus(&quot;uploading&quot;);
    setProgress(0);

    // Step 1: ask our server for a presigned URL
    const presignRes = await fetch(&quot;/api/presigned-url&quot;, {
      method: &quot;POST&quot;,
      headers: { &quot;Content-Type&quot;: &quot;application/json&quot; },
      body: JSON.stringify({ fileName: file.name, fileType: file.type }),
    });

    if (!presignRes.ok) {
      setStatus(&quot;error&quot;);
      return;
    }

    const { uploadUrl } = await presignRes.json();

    // Step 2: upload the actual file straight to S3, tracking progress
    await new Promise&amp;lt;void&amp;gt;((resolve, reject) =&amp;gt; {
      const xhr = new XMLHttpRequest();
      xhr.open(&quot;PUT&quot;, uploadUrl);
      xhr.setRequestHeader(&quot;Content-Type&quot;, file.type);

      xhr.upload.onprogress = (event) =&amp;gt; {
        if (event.lengthComputable) {
          setProgress(Math.round((event.loaded / event.total) * 100));
        }
      };

      xhr.onload = () =&amp;gt; (xhr.status === 200 ? resolve() : reject());
      xhr.onerror = () =&amp;gt; reject();
      xhr.send(file);
    })
      .then(() =&amp;gt; setStatus(&quot;done&quot;))
      .catch(() =&amp;gt; setStatus(&quot;error&quot;));
  };

  return (
    &amp;lt;form onSubmit={handleSubmit} className=&quot;flex flex-col gap-3 max-w-sm&quot;&amp;gt;
      &amp;lt;input type=&quot;file&quot; name=&quot;file&quot; accept=&quot;image/*,video/mp4&quot; required /&amp;gt;
      &amp;lt;button
        type=&quot;submit&quot;
        disabled={status === &quot;uploading&quot;}
        className=&quot;rounded-lg bg-primary px-4 py-2 text-white disabled:opacity-50&quot;
      &amp;gt;
        {status === &quot;uploading&quot; ? `Uploading... ${progress}%` : &quot;Upload&quot;}
      &amp;lt;/button&amp;gt;
      {status === &quot;done&quot; &amp;amp;&amp;amp; &amp;lt;p className=&quot;text-sm text-green-600&quot;&amp;gt;Upload complete!&amp;lt;/p&amp;gt;}
      {status === &quot;error&quot; &amp;amp;&amp;amp; &amp;lt;p className=&quot;text-sm text-destructive&quot;&amp;gt;Upload failed.&amp;lt;/p&amp;gt;}
    &amp;lt;/form&amp;gt;
  );
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;Why &lt;code&gt;XMLHttpRequest&lt;/code&gt; instead of &lt;code&gt;fetch&lt;/code&gt;?&lt;/strong&gt; This is a genuinely common question. The &lt;code&gt;fetch&lt;/code&gt; API still doesn't support upload progress tracking in a standard way — &lt;code&gt;xhr.upload.onprogress&lt;/code&gt; does. For a file that could take a while, showing real progress is worth the slightly older API.&lt;/p&gt;
&lt;blockquote&gt;&lt;p&gt;⚠️ &lt;strong&gt;Common Mistake:&lt;/strong&gt; Forgetting to set the same &lt;code&gt;Content-Type&lt;/code&gt; header on the &lt;code&gt;PUT&lt;/code&gt; request that you signed the URL with. S3 checks that they match — a mismatch fails with a signature error that has nothing to do with your actual credentials, which makes it a confusing bug to track down.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;hr&gt;
&lt;h3 id=&quot;pattern-3-multipart-uploads-for-large-files&quot;&gt;Pattern 3: Multipart Uploads for Large Files&lt;/h3&gt;
&lt;p&gt;&lt;strong&gt;How it works:&lt;/strong&gt; instead of uploading one large file as a single request, you split it into chunks (S3 calls these &quot;parts&quot;), upload each part with its own presigned URL, and then tell S3 to stitch them back together.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-txt&quot;&gt;Browser → Next.js: &quot;start a multipart upload&quot;
Browser → S3: upload part 1, part 2, part 3... (each with its own presigned URL)
Browser → Next.js: &quot;complete the upload&quot; (with the list of parts)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;This solves two real problems that plain presigned URLs don't:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Reliability&lt;/strong&gt; — if part 4 of 10 fails on a flaky connection, you only retry part 4, not the entire 500MB file&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Parallelism&lt;/strong&gt; — multiple parts can upload at the same time instead of one after another&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;AWS requires every part except the last to be &lt;strong&gt;at least 5MB&lt;/strong&gt;. This pattern is overkill for a 2MB avatar — it's built for videos, archives, and anything in the tens-to-hundreds-of-MB range.&lt;/p&gt;
&lt;h4 id=&quot;step-1-start-the-multipart-upload&quot;&gt;Step 1: Start the Multipart Upload&lt;/h4&gt;
&lt;pre&gt;&lt;code class=&quot;language-ts&quot;&gt;// app/api/multipart/start/route.ts
import { NextRequest, NextResponse } from &quot;next/server&quot;;
import { CreateMultipartUploadCommand } from &quot;@aws-sdk/client-s3&quot;;
import { s3Client } from &quot;@/lib/s3-client&quot;;
import { randomUUID } from &quot;crypto&quot;;

export async function POST(request: NextRequest) {
  const { fileName, fileType } = await request.json();

  const key = `large-uploads/${randomUUID()}-${fileName}`;

  const { UploadId } = await s3Client.send(
    new CreateMultipartUploadCommand({
      Bucket: process.env.S3_BUCKET_NAME!,
      Key: key,
      ContentType: fileType,
    })
  );

  return NextResponse.json({ uploadId: UploadId, key });
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;This tells S3 &quot;expect a multipart upload for this key&quot; and gets back an &lt;code&gt;UploadId&lt;/code&gt; — a token that ties every part together as belonging to the same file.&lt;/p&gt;
&lt;h4 id=&quot;step-2-get-a-presigned-url-for-each-part&quot;&gt;Step 2: Get a Presigned URL for Each Part&lt;/h4&gt;
&lt;pre&gt;&lt;code class=&quot;language-ts&quot;&gt;// app/api/multipart/presign-part/route.ts
import { NextRequest, NextResponse } from &quot;next/server&quot;;
import { UploadPartCommand } from &quot;@aws-sdk/client-s3&quot;;
import { getSignedUrl } from &quot;@aws-sdk/s3-request-presigner&quot;;
import { s3Client } from &quot;@/lib/s3-client&quot;;

export async function POST(request: NextRequest) {
  const { key, uploadId, partNumber } = await request.json();

  const command = new UploadPartCommand({
    Bucket: process.env.S3_BUCKET_NAME!,
    Key: key,
    UploadId: uploadId,
    PartNumber: partNumber,
  });

  const url = await getSignedUrl(s3Client, command, { expiresIn: 300 });

  return NextResponse.json({ url });
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Each part needs its own presigned URL, tagged with a &lt;code&gt;PartNumber&lt;/code&gt; (1, 2, 3...) — this is how S3 knows the order to reassemble them in later.&lt;/p&gt;
&lt;h4 id=&quot;step-3-complete-the-multipart-upload&quot;&gt;Step 3: Complete the Multipart Upload&lt;/h4&gt;
&lt;pre&gt;&lt;code class=&quot;language-ts&quot;&gt;// app/api/multipart/complete/route.ts
import { NextRequest, NextResponse } from &quot;next/server&quot;;
import { CompleteMultipartUploadCommand } from &quot;@aws-sdk/client-s3&quot;;
import { s3Client } from &quot;@/lib/s3-client&quot;;

export async function POST(request: NextRequest) {
  const { key, uploadId, parts } = await request.json();
  // parts: { PartNumber: number; ETag: string }[]

  await s3Client.send(
    new CompleteMultipartUploadCommand({
      Bucket: process.env.S3_BUCKET_NAME!,
      Key: key,
      UploadId: uploadId,
      MultipartUpload: { Parts: parts },
    })
  );

  return NextResponse.json({ success: true, key });
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;Why does every part need an &lt;code&gt;ETag&lt;/code&gt;?&lt;/strong&gt; Each time S3 accepts a part, it returns an &lt;code&gt;ETag&lt;/code&gt; in the response headers — essentially a fingerprint confirming that part uploaded correctly and intact. When you call &quot;complete,&quot; S3 cross-checks every &lt;code&gt;ETag&lt;/code&gt; you send against what it actually received before stitching the file together. This is exactly why we exposed the &lt;code&gt;ETag&lt;/code&gt; header in the CORS config back in setup.&lt;/p&gt;
&lt;blockquote&gt;&lt;p&gt;⚠️ &lt;strong&gt;Common Mistake:&lt;/strong&gt; Forgetting to add a cleanup step for &lt;strong&gt;abandoned&lt;/strong&gt; multipart uploads (user closes the tab mid-upload). S3 keeps incomplete parts around and bills you for that storage. Set up an &lt;a rel=&quot;nofollow&quot; href=&quot;https://docs.aws.amazon.com/AmazonS3/latest/userguide/mpu-abort-incomplete-mpu-lifecycle-config.html&quot;&gt;S3 Lifecycle rule&lt;/a&gt; to auto-abort incomplete uploads after a day or two.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;h4 id=&quot;step-4-orchestrate-it-all-from-the-browser&quot;&gt;Step 4: Orchestrate It All From the Browser&lt;/h4&gt;
&lt;pre&gt;&lt;code class=&quot;language-tsx&quot;&gt;// components/multipart-upload-form.tsx
&quot;use client&quot;;

import { useState } from &quot;react&quot;;

const CHUNK_SIZE = 6 * 1024 * 1024; // 6MB per part (must be 5MB+)
// S3 requires every part except the last to be at least 5MB. Choosing 6MB provides a small buffer while keeping memory usage reasonable.

export function MultipartUploadForm() {
  const [progress, setProgress] = useState(0);
  const [status, setStatus] = useState&amp;lt;&quot;idle&quot; | &quot;uploading&quot; | &quot;done&quot; | &quot;error&quot;&amp;gt;(&quot;idle&quot;);

  const uploadPart = async (
    url: string,
    chunk: Blob
  ): Promise&amp;lt;string&amp;gt; =&amp;gt; {
    const res = await fetch(url, { method: &quot;PUT&quot;, body: chunk });
    const etag = res.headers.get(&quot;ETag&quot;);
    if (!etag) throw new Error(&quot;Missing ETag from S3 response&quot;);
    return etag;
  };

  const handleSubmit = async (e: React.FormEvent&amp;lt;HTMLFormElement&amp;gt;) =&amp;gt; {
    e.preventDefault();
    const form = e.currentTarget;
    const fileInput = form.elements.namedItem(&quot;file&quot;) as HTMLInputElement;
    const file = fileInput.files?.[0];
    if (!file) return;

    setStatus(&quot;uploading&quot;);
    setProgress(0);

    try {
      // Step 1: start the multipart upload
      const startRes = await fetch(&quot;/api/multipart/start&quot;, {
        method: &quot;POST&quot;,
        headers: { &quot;Content-Type&quot;: &quot;application/json&quot; },
        body: JSON.stringify({ fileName: file.name, fileType: file.type }),
      });
      const { uploadId, key } = await startRes.json();

      // Step 2: split the file into chunks and upload each one
      const totalParts = Math.ceil(file.size / CHUNK_SIZE);
      const completedParts: { PartNumber: number; ETag: string }[] = [];

      for (let partNumber = 1; partNumber &amp;lt;= totalParts; partNumber++) {
        const start = (partNumber - 1) * CHUNK_SIZE;
        const chunk = file.slice(start, start + CHUNK_SIZE);

        const presignRes = await fetch(&quot;/api/multipart/presign-part&quot;, {
          method: &quot;POST&quot;,
          headers: { &quot;Content-Type&quot;: &quot;application/json&quot; },
          body: JSON.stringify({ key, uploadId, partNumber }),
        });
        const { url } = await presignRes.json();

        const etag = await uploadPart(url, chunk);
        completedParts.push({ PartNumber: partNumber, ETag: etag });

        setProgress(Math.round((partNumber / totalParts) * 100));
      }

      // Step 3: tell S3 to assemble the parts into the final file
      await fetch(&quot;/api/multipart/complete&quot;, {
        method: &quot;POST&quot;,
        headers: { &quot;Content-Type&quot;: &quot;application/json&quot; },
        body: JSON.stringify({ key, uploadId, parts: completedParts }),
      });

      setStatus(&quot;done&quot;);
    } catch (error) {
      console.error(&quot;Multipart upload failed:&quot;, error);
      setStatus(&quot;error&quot;);
    }
  };

  return (
    &amp;lt;form onSubmit={handleSubmit} className=&quot;flex flex-col gap-3 max-w-sm&quot;&amp;gt;
      &amp;lt;input type=&quot;file&quot; name=&quot;file&quot; required /&amp;gt;
      &amp;lt;button
        type=&quot;submit&quot;
        disabled={status === &quot;uploading&quot;}
        className=&quot;rounded-lg bg-primary px-4 py-2 text-white disabled:opacity-50&quot;
      &amp;gt;
        {status === &quot;uploading&quot; ? `Uploading... ${progress}%` : &quot;Upload Large File&quot;}
      &amp;lt;/button&amp;gt;
      {status === &quot;done&quot; &amp;amp;&amp;amp; &amp;lt;p className=&quot;text-sm text-green-600&quot;&amp;gt;Upload complete!&amp;lt;/p&amp;gt;}
      {status === &quot;error&quot; &amp;amp;&amp;amp; &amp;lt;p className=&quot;text-sm text-destructive&quot;&amp;gt;Upload failed.&amp;lt;/p&amp;gt;}
    &amp;lt;/form&amp;gt;
  );
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;Walking through the flow:&lt;/strong&gt; we start the upload and get back an &lt;code&gt;uploadId&lt;/code&gt;, then loop through the file in 6MB slices using &lt;code&gt;file.slice()&lt;/code&gt; — this is a native browser API, no library needed. For each slice, we request a fresh presigned URL, &lt;code&gt;PUT&lt;/code&gt; the chunk directly to S3, and hold onto the &lt;code&gt;ETag&lt;/code&gt; we get back. Once every part is uploaded, we send the full list of &lt;code&gt;{ PartNumber, ETag }&lt;/code&gt; pairs to the &quot;complete&quot; endpoint, and S3 assembles them into one file.&lt;/p&gt;
&lt;blockquote&gt;&lt;p&gt;💡 &lt;strong&gt;Tip:&lt;/strong&gt; The loop above uploads parts one at a time for clarity. In production, you'd typically run several &lt;code&gt;uploadPart&lt;/code&gt; calls concurrently (e.g. 3–4 at once with &lt;code&gt;Promise.all&lt;/code&gt;) to meaningfully speed up large uploads — just be sure to still track and report progress per part.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;hr&gt;
&lt;h3 id=&quot;which-pattern-should-you-actually-use-&quot;&gt;Which Pattern Should You Actually Use?&lt;/h3&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;&lt;th&gt;Situation &lt;/th&gt;&lt;th&gt;Pattern&lt;/th&gt;&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;&lt;td&gt;Small files (avatars, PDFs under ~5MB) &lt;/td&gt;&lt;td&gt;Server-side proxying&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;You need to validate/process the file server-side before storing it &lt;/td&gt;&lt;td&gt;Server-side proxying&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;User-facing images or short videos, a few MB to ~50MB &lt;/td&gt;&lt;td&gt;Direct-to-S3 presigned URL&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;You want upload progress without server load &lt;/td&gt;&lt;td&gt;Direct-to-S3 presigned URL&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Large files — videos, archives, datasets (50MB+) &lt;/td&gt;&lt;td&gt;Multipart upload&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Uploads over unreliable connections that need retry-per-chunk &lt;/td&gt;&lt;td&gt;Multipart upload&lt;/td&gt;&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;A common real-world setup actually &lt;strong&gt;combines&lt;/strong&gt; these: use a presigned URL (single or multipart, depending on size) to get the file into S3 quickly, then have S3 trigger a Lambda or your own webhook afterward to run any validation, resizing, or processing you'd otherwise have done during a server-side proxy.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;frequently-asked-questions&quot;&gt;Frequently Asked Questions&lt;/h3&gt;
&lt;h4 id=&quot;do-i-need-to-use-the-aws-sdk-or-can-i-just-use-fetch-with-s3-s-rest-api-directly-&quot;&gt;Do I need to use the AWS SDK, or can I just use fetch with S3's REST API directly?&lt;/h4&gt;
&lt;p&gt;You technically can call S3's REST API directly, but you'd be manually implementing AWS's request-signing algorithm (SigV4) yourself — the SDK handles that correctly and is the standard approach. Stick with &lt;code&gt;@aws-sdk/client-s3&lt;/code&gt; unless you have a very specific reason not to.&lt;/p&gt;
&lt;h4 id=&quot;is-it-safe-to-expose-the-s3-object-key-to-the-browser-&quot;&gt;Is it safe to expose the S3 object key to the browser?&lt;/h4&gt;
&lt;p&gt;Yes — the key is just a file path, not a credential. What actually grants access is the signed portion of the presigned URL, which expires and is scoped to that one action. Just don't put sensitive information (like a user's email) directly in the key name if the bucket could ever become browsable.&lt;/p&gt;
&lt;h4 id=&quot;can-i-use-this-with-the-pages-router-instead-of-app-router-&quot;&gt;Can I use this with the Pages Router instead of App Router?&lt;/h4&gt;
&lt;p&gt;The S3 SDK logic (creating the client, generating presigned URLs) works identically. You'd just swap the Route Handlers for &lt;code&gt;pages/api/*.ts&lt;/code&gt; files using &lt;code&gt;req&lt;/code&gt;/&lt;code&gt;res&lt;/code&gt; instead of &lt;code&gt;NextRequest&lt;/code&gt;/&lt;code&gt;NextResponse&lt;/code&gt;.&lt;/p&gt;
&lt;h4 id=&quot;what-happens-if-a-presigned-url-expires-mid-upload-&quot;&gt;What happens if a presigned URL expires mid-upload?&lt;/h4&gt;
&lt;p&gt;For the single presigned URL pattern, a slow upload could in theory outlive a very short expiry — that's why the expiry is a balance: long enough to comfortably start and finish, short enough to limit exposure if leaked. For multipart uploads, each part gets its own fresh URL, so this is naturally less of a concern for large files.&lt;/p&gt;
&lt;h4 id=&quot;do-i-need-cloudfront-in-front-of-my-bucket-&quot;&gt;Do I need CloudFront in front of my bucket?&lt;/h4&gt;
&lt;p&gt;Not for uploads — CloudFront is about serving files back out fast, which is a separate concern from getting files in. If you're also displaying these uploaded files to users at scale, adding a CloudFront distribution in front of the bucket is a common next step, but it's optional for the upload flow itself. See my &lt;a rel=&quot;nofollow&quot; href=&quot;https://devstacked.tech/blog/s3-display-images-nextjs-16-guide&quot;&gt;guide on displaying S3 images&lt;/a&gt; for the full breakdown of the public, presigned, and CloudFront display patterns.&lt;/p&gt;
&lt;h4 id=&quot;should-i-use-server-actions-for-uploads-&quot;&gt;Should I use Server Actions for uploads?&lt;/h4&gt;
&lt;p&gt;Server Actions work well for small files, but they still route the file through your server. The same trade-offs apply: body size limits, execution time, and bandwidth costs. For larger uploads, presigned URLs remain the recommended approach.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;wrapping-up&quot;&gt;Wrapping Up&lt;/h3&gt;
&lt;p&gt;You now have three working, production-grade upload patterns for S3 in Next.js 16 — server-side proxying for small files you need to inspect, presigned URLs for everyday user uploads, and multipart uploads for the big stuff that needs to survive a bad connection. The pattern you reach for isn't really an S3 question — it's a question of how much of that file actually needs to pass through your server, and now you've got the code to implement whichever answer fits.&lt;/p&gt;
&lt;p&gt;From here, the natural next step is &lt;a rel=&quot;nofollow&quot; href=&quot;https://devstacked.tech/blog/s3-display-images-nextjs-16-guide&quot;&gt;displaying these uploaded files back to your users&lt;/a&gt; — covering public URLs, presigned GET links, and CloudFront delivery.&lt;/p&gt;
&lt;p&gt;📦 &lt;strong&gt;Source Code:&lt;/strong&gt; &lt;a rel=&quot;nofollow&quot; href=&quot;https://github.com/Muhammad-Ali-sma/next-s3-setup&quot;&gt;View on GitHub&lt;/a&gt;&lt;/p&gt;
&lt;h3 id=&quot;continue-learning&quot;&gt;Continue Learning&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;&lt;a rel=&quot;nofollow&quot; href=&quot;https://devstacked.tech/blog/s3-display-images-nextjs-16-guide&quot;&gt;How to Display Images from S3 in Next.js 16 (2026 Guide)&lt;/a&gt; — now that your files are in S3, here's how to show them back to users safely&lt;/li&gt;
&lt;li&gt;&lt;a rel=&quot;nofollow&quot; href=&quot;https://devstacked.tech/blog/resend-nextjs-16-guide&quot;&gt;How to Setup Resend Email in Next.js 16 (2026 Complete Beginner Guide)&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a rel=&quot;nofollow&quot; href=&quot;https://devstacked.tech/blog/saas-starter-architecture-nextjs&quot;&gt;SaaS Starter Architecture with Next.js 2026&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a rel=&quot;nofollow&quot; href=&quot;https://devstacked.tech/blog/nextjs-rendering-strategies-explained&quot;&gt;Next.js Rendering Strategies Explained: SSR, SSG, CSR, ISR &amp;amp; Cache Components (2026)&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;
</description>
<category>Articles</category>
<guid isPermaLink="true">https://coderlegion.com/23910/next-js-16-aws-s3-file-upload-guide-presigned-urls-proxy-multipart</guid>
<pubDate>Sat, 01 Aug 2026 19:00:00 +0000</pubDate>
</item>
</channel>
</rss>