Next.js 16 AWS S3 File Upload Guide: Presigned URLs, Proxy & Multipart
At some point, almost every real app needs file uploads — profile pictures, PDFs, videos, CSV imports. The tempting move is to just POST 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.
The fix is almost always Amazon S3 (Simple Storage Service) — a place built specifically to store files cheaply and reliably, so your Next.js app doesn't have to. But "just use S3" 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.
In this guide, you'll build all three real-world upload patterns in Next.js 16:
- Server-side proxying — the file passes through your Next.js server on its way to S3
- Direct-to-S3 presigned URLs — the browser uploads straight to S3, skipping your server entirely
- Multipart uploads — for large files, split into chunks so they upload reliably and can resume
By the end, you'll know exactly which pattern to reach for and have working code for each.
Why This Is Tricky
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.
- Body size limits — most hosting platforms cap how much data a single request can carry (Vercel's default is 4.5MB for serverless functions).
- Function timeouts — a slow upload over a weak connection can outlive your function's execution limit.
- You pay for time, not just requests — 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.
So the real skill here isn't "how do I upload to S3" — it's "how much of this upload actually needs to touch my server?" That question is what decides which of the three patterns you reach for.
💡 Tip: 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.
Prerequisites & Setup
This guide assumes:
- Next.js 16 with the App Router
- TypeScript in strict mode
- Tailwind CSS for the (minimal) UI examples
- An AWS account with an S3 bucket
Step 1: Create an S3 Bucket
In the AWS Console:
- Click Create bucket
- Give it a globally unique name, e.g.
devstacked-uploads-demo - Pick a region close to your users
- Leave Block all public access checked — you don't need a public bucket for any of these three patterns
⚠️ Common Mistake: Making the bucket public "to make uploads easier." None of the patterns in this guide need a public bucket. Presigned URLs grant temporary, scoped access without ever exposing the bucket itself.
Step 2: Create an IAM User for Programmatic Access
Your Next.js server needs credentials to talk to S3.
- Go to IAM → Users → Create user
- Name it something like
nextjs-s3-uploader - Attach a policy scoped to just this bucket — not full
AmazonS3FullAccess:
// s3-upload-policy.json
{
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Action": [
"s3:PutObject",
"s3:GetObject",
"s3:AbortMultipartUpload",
"s3:ListMultipartUploadParts"
],
"Resource": "arn:aws:s3:::devstacked-uploads-demo/*"
}
]
}
- After creating the user, open it and generate an Access key
⚠️ Common Mistake: Using your AWS root account credentials in
.env.local. Always create a scoped IAM user — if those keys ever leak, the blast radius is one bucket, not your entire AWS account.
Step 3: Configure CORS on the Bucket
Two of the three patterns in this guide (presigned URLs and multipart) have the browser talk directly to S3. By default, S3 blocks cross-origin requests from your site, so you need to explicitly allow it.
In your bucket → Permissions → Cross-origin resource sharing (CORS):
// s3-cors-config.json
[
{
"AllowedHeaders": ["*"],
"AllowedMethods": ["PUT", "POST", "GET"],
"AllowedOrigins": ["http://localhost:3000", "https://yourdomain.com"],
"ExposeHeaders": ["ETag"]
}
]
Why ExposeHeaders: ["ETag"] matters: the multipart pattern later needs to read the ETag 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.
💡 Tip: Consider enabling S3 Versioning from
Properties Tabif users may overwrite files. It provides recovery from accidental deletes or replacements.
Step 4: Install Dependencies
npm install @aws-sdk/client-s3 @aws-sdk/s3-request-presigner
@aws-sdk/client-s3— the official AWS SDK v3 client for talking to S3@aws-sdk/s3-request-presigner— generates temporary, signed upload URLs
Step 5: Environment Variables
# .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
⚠️ Common Mistake: Never prefix these with
NEXT_PUBLIC_. That would ship your AWS secret key to every visitor's browser. These stay server-only, always.
Step 6: Create a Reusable S3 Client
// lib/s3-client.ts
import { S3Client } from "@aws-sdk/client-s3";
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!,
},
});
Every pattern below imports this single client instead of creating a new one per request.
Pattern 1: Server-Side Proxying
How it works: 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.
Browser → Next.js Route Handler → S3
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 small files only (a few MB at most).
Step 1: Create the Upload Route Handler
// app/api/upload/route.ts
import { NextRequest, NextResponse } from "next/server";
import { PutObjectCommand } from "@aws-sdk/client-s3";
import { s3Client } from "@/lib/s3-client";
import { randomUUID } from "crypto";
const MAX_FILE_SIZE = 5 * 1024 * 1024; // 5MB
const ALLOWED_TYPES = ["image/jpeg", "image/png", "image/webp"];
export async function POST(request: NextRequest) {
try {
const formData = await request.formData();
const file = formData.get("file") as File | null;
if (!file) {
return NextResponse.json({ error: "No file provided" }, { status: 400 });
}
if (!ALLOWED_TYPES.includes(file.type)) {
return NextResponse.json({ error: "Unsupported file type" }, { status: 400 });
}
if (file.size > MAX_FILE_SIZE) {
return NextResponse.json({ error: "File too large" }, { 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("Upload error:", error);
return NextResponse.json({ error: "Upload failed" }, { status: 500 });
}
}
What's happening here:
request.formData()reads the incoming multipart form data — this is how browsers send files- We validate
file.typeandfile.sizebefore touching S3, so bad requests fail fast and cheap file.arrayBuffer()converts the browserFileobject into aBufferthat the AWS SDK can send- The S3 object
Key(its path inside the bucket) includes a random UUID so two people uploadingphoto.jpgnever overwrite each other
⚠️ Common Mistake: Trusting
file.typealone 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 "safe."
Step 2: Build the Upload Form
// components/proxy-upload-form.tsx
"use client";
import { useState } from "react";
export function ProxyUploadForm() {
const [uploading, setUploading] = useState(false);
const [message, setMessage] = useState("");
const handleSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault();
const form = e.currentTarget;
const fileInput = form.elements.namedItem("file") as HTMLInputElement;
const file = fileInput.files?.[0];
if (!file) return;
setUploading(true);
setMessage("");
const formData = new FormData();
formData.append("file", file);
const res = await fetch("/api/upload", {
method: "POST",
body: formData,
});
const data = await res.json();
setUploading(false);
setMessage(res.ok ? `Uploaded: ${data.key}` : data.error);
};
return (
<form onSubmit={handleSubmit} className="flex flex-col gap-3 max-w-sm">
<input type="file" name="file" accept="image/*" required />
<button
type="submit"
disabled={uploading}
className="rounded-lg bg-primary px-4 py-2 text-white disabled:opacity-50"
>
{uploading ? "Uploading..." : "Upload"}
</button>
{message && <p className="text-sm text-muted-foreground">{message}</p>}
</form>
);
}
Nothing exotic here — a standard FormData 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.
Pattern 2: Direct-to-S3 Presigned URLs
How it works: your server never touches the file. Instead, it asks S3 for a presigned URL — 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.
Browser → Next.js (ask for permission) → gets presigned URL
Browser → S3 directly (actual file upload)
Think of a presigned URL like a guest pass: 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.
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: "yes, generate a URL" or "no."
Step 1: Generate the Presigned URL
// app/api/presigned-url/route.ts
import { NextRequest, NextResponse } from "next/server";
import { PutObjectCommand } from "@aws-sdk/client-s3";
import { getSignedUrl } from "@aws-sdk/s3-request-presigner";
import { s3Client } from "@/lib/s3-client";
import { randomUUID } from "crypto";
const ALLOWED_TYPES = ["image/jpeg", "image/png", "video/mp4"];
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: "Missing fileName or fileType" }, { status: 400 });
}
if (!ALLOWED_TYPES.includes(fileType)) {
return NextResponse.json({ error: "Unsupported file type" }, { 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("Presign error:", error);
return NextResponse.json({ error: "Could not generate upload URL" }, { status: 500 });
}
}
What's happening here:
- We never receive the actual file — just its
fileNameandfileType, which is why this endpoint responds instantly no matter how big the file will be getSignedUrl()doesn't make a network call to AWS — it cryptographically signs the request locally using your IAM credentials, so it's fastexpiresIn: 60means 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
💡 Tip: Still validate
fileTypehere even though you're not seeing the actual bytes. It's your only real gate at this stage — S3 will happily accept whateverContentTypethe presigned request specifies.
Step 2: Upload Directly From the Browser
// components/presigned-upload-form.tsx
"use client";
import { useState } from "react";
export function PresignedUploadForm() {
const [progress, setProgress] = useState(0);
const [status, setStatus] = useState<"idle" | "uploading" | "done" | "error">("idle");
const handleSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault();
const form = e.currentTarget;
const fileInput = form.elements.namedItem("file") as HTMLInputElement;
const file = fileInput.files?.[0];
if (!file) return;
setStatus("uploading");
setProgress(0);
// Step 1: ask our server for a presigned URL
const presignRes = await fetch("/api/presigned-url", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ fileName: file.name, fileType: file.type }),
});
if (!presignRes.ok) {
setStatus("error");
return;
}
const { uploadUrl } = await presignRes.json();
// Step 2: upload the actual file straight to S3, tracking progress
await new Promise<void>((resolve, reject) => {
const xhr = new XMLHttpRequest();
xhr.open("PUT", uploadUrl);
xhr.setRequestHeader("Content-Type", file.type);
xhr.upload.onprogress = (event) => {
if (event.lengthComputable) {
setProgress(Math.round((event.loaded / event.total) * 100));
}
};
xhr.onload = () => (xhr.status === 200 ? resolve() : reject());
xhr.onerror = () => reject();
xhr.send(file);
})
.then(() => setStatus("done"))
.catch(() => setStatus("error"));
};
return (
<form onSubmit={handleSubmit} className="flex flex-col gap-3 max-w-sm">
<input type="file" name="file" accept="image/*,video/mp4" required />
<button
type="submit"
disabled={status === "uploading"}
className="rounded-lg bg-primary px-4 py-2 text-white disabled:opacity-50"
>
{status === "uploading" ? `Uploading... ${progress}%` : "Upload"}
</button>
{status === "done" && <p className="text-sm text-green-600">Upload complete!</p>}
{status === "error" && <p className="text-sm text-destructive">Upload failed.</p>}
</form>
);
}
Why XMLHttpRequest instead of fetch? This is a genuinely common question. The fetch API still doesn't support upload progress tracking in a standard way — xhr.upload.onprogress does. For a file that could take a while, showing real progress is worth the slightly older API.
⚠️ Common Mistake: Forgetting to set the same
Content-Typeheader on thePUTrequest 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.
Pattern 3: Multipart Uploads for Large Files
How it works: instead of uploading one large file as a single request, you split it into chunks (S3 calls these "parts"), upload each part with its own presigned URL, and then tell S3 to stitch them back together.
Browser → Next.js: "start a multipart upload"
Browser → S3: upload part 1, part 2, part 3... (each with its own presigned URL)
Browser → Next.js: "complete the upload" (with the list of parts)
This solves two real problems that plain presigned URLs don't:
- Reliability — if part 4 of 10 fails on a flaky connection, you only retry part 4, not the entire 500MB file
- Parallelism — multiple parts can upload at the same time instead of one after another
AWS requires every part except the last to be at least 5MB. This pattern is overkill for a 2MB avatar — it's built for videos, archives, and anything in the tens-to-hundreds-of-MB range.
Step 1: Start the Multipart Upload
// app/api/multipart/start/route.ts
import { NextRequest, NextResponse } from "next/server";
import { CreateMultipartUploadCommand } from "@aws-sdk/client-s3";
import { s3Client } from "@/lib/s3-client";
import { randomUUID } from "crypto";
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 });
}
This tells S3 "expect a multipart upload for this key" and gets back an UploadId — a token that ties every part together as belonging to the same file.
Step 2: Get a Presigned URL for Each Part
// app/api/multipart/presign-part/route.ts
import { NextRequest, NextResponse } from "next/server";
import { UploadPartCommand } from "@aws-sdk/client-s3";
import { getSignedUrl } from "@aws-sdk/s3-request-presigner";
import { s3Client } from "@/lib/s3-client";
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 });
}
Each part needs its own presigned URL, tagged with a PartNumber (1, 2, 3...) — this is how S3 knows the order to reassemble them in later.
Step 3: Complete the Multipart Upload
// app/api/multipart/complete/route.ts
import { NextRequest, NextResponse } from "next/server";
import { CompleteMultipartUploadCommand } from "@aws-sdk/client-s3";
import { s3Client } from "@/lib/s3-client";
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 });
}
Why does every part need an ETag? Each time S3 accepts a part, it returns an ETag in the response headers — essentially a fingerprint confirming that part uploaded correctly and intact. When you call "complete," S3 cross-checks every ETag you send against what it actually received before stitching the file together. This is exactly why we exposed the ETag header in the CORS config back in setup.
⚠️ Common Mistake: Forgetting to add a cleanup step for abandoned multipart uploads (user closes the tab mid-upload). S3 keeps incomplete parts around and bills you for that storage. Set up an S3 Lifecycle rule to auto-abort incomplete uploads after a day or two.
Step 4: Orchestrate It All From the Browser
// components/multipart-upload-form.tsx
"use client";
import { useState } from "react";
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<"idle" | "uploading" | "done" | "error">("idle");
const uploadPart = async (
url: string,
chunk: Blob
): Promise<string> => {
const res = await fetch(url, { method: "PUT", body: chunk });
const etag = res.headers.get("ETag");
if (!etag) throw new Error("Missing ETag from S3 response");
return etag;
};
const handleSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault();
const form = e.currentTarget;
const fileInput = form.elements.namedItem("file") as HTMLInputElement;
const file = fileInput.files?.[0];
if (!file) return;
setStatus("uploading");
setProgress(0);
try {
// Step 1: start the multipart upload
const startRes = await fetch("/api/multipart/start", {
method: "POST",
headers: { "Content-Type": "application/json" },
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 <= totalParts; partNumber++) {
const start = (partNumber - 1) * CHUNK_SIZE;
const chunk = file.slice(start, start + CHUNK_SIZE);
const presignRes = await fetch("/api/multipart/presign-part", {
method: "POST",
headers: { "Content-Type": "application/json" },
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("/api/multipart/complete", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ key, uploadId, parts: completedParts }),
});
setStatus("done");
} catch (error) {
console.error("Multipart upload failed:", error);
setStatus("error");
}
};
return (
<form onSubmit={handleSubmit} className="flex flex-col gap-3 max-w-sm">
<input type="file" name="file" required />
<button
type="submit"
disabled={status === "uploading"}
className="rounded-lg bg-primary px-4 py-2 text-white disabled:opacity-50"
>
{status === "uploading" ? `Uploading... ${progress}%` : "Upload Large File"}
</button>
{status === "done" && <p className="text-sm text-green-600">Upload complete!</p>}
{status === "error" && <p className="text-sm text-destructive">Upload failed.</p>}
</form>
);
}
Walking through the flow: we start the upload and get back an uploadId, then loop through the file in 6MB slices using file.slice() — this is a native browser API, no library needed. For each slice, we request a fresh presigned URL, PUT the chunk directly to S3, and hold onto the ETag we get back. Once every part is uploaded, we send the full list of { PartNumber, ETag } pairs to the "complete" endpoint, and S3 assembles them into one file.
💡 Tip: The loop above uploads parts one at a time for clarity. In production, you'd typically run several
uploadPartcalls concurrently (e.g. 3–4 at once withPromise.all) to meaningfully speed up large uploads — just be sure to still track and report progress per part.
Which Pattern Should You Actually Use?
| Situation | Pattern |
|---|---|
| Small files (avatars, PDFs under ~5MB) | Server-side proxying |
| You need to validate/process the file server-side before storing it | Server-side proxying |
| User-facing images or short videos, a few MB to ~50MB | Direct-to-S3 presigned URL |
| You want upload progress without server load | Direct-to-S3 presigned URL |
| Large files — videos, archives, datasets (50MB+) | Multipart upload |
| Uploads over unreliable connections that need retry-per-chunk | Multipart upload |
A common real-world setup actually combines 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.
Frequently Asked Questions
Do I need to use the AWS SDK, or can I just use fetch with S3's REST API directly?
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 @aws-sdk/client-s3 unless you have a very specific reason not to.
Is it safe to expose the S3 object key to the browser?
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.
Can I use this with the Pages Router instead of App Router?
The S3 SDK logic (creating the client, generating presigned URLs) works identically. You'd just swap the Route Handlers for pages/api/*.ts files using req/res instead of NextRequest/NextResponse.
What happens if a presigned URL expires mid-upload?
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.
Do I need CloudFront in front of my bucket?
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.
Should I use Server Actions for uploads?
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.
Wrapping Up
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.
From here, natural next steps include adding server-side image resizing with S3 Event Notifications + Lambda, generating signed download URLs for private files, or wiring up virus scanning before a file is considered fully "uploaded."
📦 Source Code: View on GitHub