The naive file upload implementation — multipart form to your Next.js API route, then re-upload to S3 — doesn't scale. Every file passes through your server, consuming memory, bandwidth, and execution time. The production pattern uses S3 presigned URLs: your server generates a temporary signed URL, the client uploads directly to S3, and your server only records the reference. I've implemented this pattern across multiple projects including an ERP document management system, and it handles everything from profile photos to 500MB PDF reports.
The flow has three steps: (1) Client requests a presigned URL from your Next.js API route, sending only the file metadata (name, size, content type). (2) Your API route calls AWS SDK to generate a presigned PUT URL — a time-limited signed URL that allows the holder to upload one file to a specific S3 key. (3) Client uses the presigned URL to PUT the file directly to S3 from the browser — your server never touches the file bytes. After upload, the client notifies your API to record the S3 key in the database.
Presigned URLs are powerful but the server must validate before generating them: authenticate the user (who is requesting the upload?), validate file size limits (prevent 10GB uploads), validate allowed content types (only images for avatars, only PDFs for invoices), and generate a server-controlled S3 key (never let the client choose the file path — use uuid() + extension, not the original filename). Set the presigned URL expiry short — 60 seconds is plenty for the client to initiate the upload.
Naive approach (DON'T do this at scale):
Browser ──── multipart form ──── Next.js API ──── re-upload ──── S3
(file bytes flow through your server = memory + bandwidth = bottleneck)
Production approach (presigned URL):
┌─────────┐ 1. POST /api/upload-url ┌──────────────┐
│ Browser │ {name, size, type} ──────►│ Next.js API │
│ │◄────── presignedUrl ─────── │ (server-side)│
│ │ │ AWS SDK call │
│ │ 2. PUT presignedUrl └──────────────┘
│ │ (file bytes direct) │
│ │ ──────────────────────────────────►│ S3
│ │◄── 200 OK (upload complete) │
│ │ │
│ │ 3. POST /api/confirm │
│ │ {s3Key, fileId} ┌──────────────┐
│ │ ────────────────────────► │ PostgreSQL │
└─────────┘ │ (record ref) │
└──────────────┘From building an ERP document management system: use S3's server-side conditions in the presigned URL to enforce limits at the S3 level, not just in your API validation. Add ContentLengthRange conditions so even if your validation is bypassed, S3 will reject uploads outside the allowed size range. This defense-in-depth approach caught multiple attempted abuse cases in production where users modified the client-side validation.
The presigned URL generation lives in a Server Action or Route Handler. Install @aws-sdk/client-s3 and @aws-sdk/s3-request-presigner. Never expose AWS credentials to the client — all AWS SDK calls happen server-side. Configure the S3 client once with your region and credentials from environment variables. The PutObjectCommand combined with getSignedUrl() from s3-request-presigner generates the presigned URL. Set an appropriate expiry (60-300 seconds) and return only the URL to the client.
# Install AWS SDK v3 (modular)
npm install @aws-sdk/client-s3 @aws-sdk/s3-request-presigner
# app/api/upload-url/route.ts
import { S3Client, PutObjectCommand } from "@aws-sdk/client-s3"
import { getSignedUrl } from "@aws-sdk/s3-request-presigner"
import { auth } from "@/lib/auth"
import { nanoid } from "nanoid"
const s3 = new S3Client({
region: process.env.AWS_REGION!,
credentials: {
accessKeyId: process.env.AWS_ACCESS_KEY_ID!,
secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY!,
},
})
const ALLOWED_TYPES = ["image/jpeg", "image/png", "image/webp", "application/pdf"]
const MAX_SIZE_BYTES = 10 * 1024 * 1024 // 10MB
export async function POST(req: Request) {
const session = await auth()
if (!session) return Response.json({ error: "Unauthorized" }, { status: 401 })
const { name, size, contentType } = await req.json()
// Server-side validation (never trust client)
if (!ALLOWED_TYPES.includes(contentType))
return Response.json({ error: "File type not allowed" }, { status: 400 })
if (size > MAX_SIZE_BYTES)
return Response.json({ error: "File too large" }, { status: 400 })
// Server-controlled S3 key (never let client choose the path)
const ext = name.split(".").pop()?.toLowerCase()
const key = `uploads/${session.user.id}/${nanoid()}.${ext}`
const command = new PutObjectCommand({
Bucket: process.env.AWS_S3_BUCKET!,
Key: key,
ContentType: contentType,
ContentLength: size,
})
const presignedUrl = await getSignedUrl(s3, command, { expiresIn: 60 })
return Response.json({ presignedUrl, key })
}
# Client-side upload
const { presignedUrl, key } = await fetch("/api/upload-url", {
method: "POST",
body: JSON.stringify({ name: file.name, size: file.size, contentType: file.type }),
}).then(r => r.json())
await fetch(presignedUrl, {
method: "PUT",
body: file,
headers: { "Content-Type": file.type },
})
// Then confirm with your API
await fetch("/api/upload-confirm", { method: "POST", body: JSON.stringify({ key }) })Single presigned PUT requests work for files up to 5GB, but for files over ~100MB, use S3's multipart upload API. The client splits the file into 5-10MB chunks, requests a presigned URL for each part, uploads parts in parallel (for speed), then calls CompleteMultipartUpload. The ETag from each part's response is required to complete the upload — but CORS must expose the ETag header from S3, or your browser won't be able to read it. Add ETag to S3's ExposeHeaders in your CORS configuration.
Direct browser-to-S3 uploads require a proper CORS configuration on your S3 bucket. Without it, the browser blocks the PUT request. The minimum CORS config: allow PUT (and OPTIONS for preflight) from your app's domain, expose ETag in headers for multipart upload tracking, and set a reasonable MaxAgeSeconds for preflight caching. Test CORS in staging before production — CORS errors are easy to diagnose locally but often appear differently across environments due to protocol differences (http vs https).
After the client completes the S3 upload, it must notify your API to record the S3 key in your database. This is the confirmation step — without it, files uploaded to S3 have no database record and are effectively orphaned. I use a two-phase approach: generate a pending upload record in PostgreSQL when creating the presigned URL, then mark it confirmed when the client reports success. A background job sweeps for pending uploads older than 1 hour and deletes the corresponding S3 objects — cleaning up abandoned uploads.
This presigned URL pattern is the standard approach for any production Next.js app. I use @aws-sdk/client-s3 v3 (the modular SDK — no need to import the entire AWS SDK), generate presigned URLs in Next.js Route Handlers (not Server Actions, since file upload UI typically involves client-side progress tracking), and store only the S3 key in PostgreSQL via Prisma. For serving files, I use CloudFront CDN in front of S3 for caching and performance — the S3 URL is never exposed directly to clients.
AWS S3 egress costs add up for applications with heavy download traffic. Cloudflare R2 is S3-compatible (same SDK, same presigned URL pattern) with zero egress fees. For new projects, I evaluate R2 first — the only reason to choose S3 is if you're already deep in the AWS ecosystem or need specific S3 features R2 doesn't support yet. The presigned URL code is identical between S3 and R2; you just change the endpoint URL.