// LOADING
// LOADING
// LOADING_ARTICLE
The naive approach to file uploads — POST to your API, server reads the body, server forwards to S3 — has a real cost. Every megabyte passes through your serverless function twice, burning both bandwidth and execution time. On platforms like Vercel, function payload limits (typically 4.5 MB) make this approach break immediately for real-world files.
The better pattern is direct-to-S3 uploads using presigned URLs: the client gets a short-lived signed URL from your server, then uploads directly to S3 without the file ever touching your application server. Your server only handles a small metadata request and a post-upload confirmation.
This connects well with securing REST APIs — presigned URLs are themselves a security mechanism, and layering them correctly matters.
Install the AWS SDK v3 S3 client:
bashnpm install @aws-sdk/client-s3 @aws-sdk/s3-request-presigner
typescript// app/api/uploads/presign/route.ts import { NextRequest, NextResponse } from 'next/server'; import { S3Client, PutObjectCommand } from '@aws-sdk/client-s3'; import { getSignedUrl } from '@aws-sdk/s3-request-presigner'; import { randomUUID } from 'crypto'; 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 = new Set(['image/jpeg', 'image/png', 'image/webp', 'application/pdf']); const MAX_SIZE_BYTES = 10 * 1024 * 1024; // 10 MB export async function POST(req: NextRequest) { // TODO: verify auth session here const { filename, contentType, size } = await req.json(); if (!ALLOWED_TYPES.has(contentType)) { return NextResponse.json({ error: 'Unsupported file type' }, { status: 400 }); } if (size > MAX_SIZE_BYTES) { return NextResponse.json({ error: 'File too large' }, { status: 400 }); } const key = `uploads/${randomUUID()}-${filename.replace(/[^a-zA-Z0-9._-]/g, '_')}`; const command = new PutObjectCommand({ Bucket: process.env.S3_BUCKET_NAME!, Key: key, ContentType: contentType, ContentLength: size, }); const url = await getSignedUrl(s3, command, { expiresIn: 300 }); // 5 minutes return NextResponse.json({ url, key }); }
Note that ContentLength in the PutObjectCommand binds the presigned URL to the exact file size the client declared. A mismatch causes S3 to reject the upload, preventing a client from claiming a 1 KB file and then uploading 100 MB.
typescript// hooks/useFileUpload.ts export async function uploadFile(file: File): Promise<string> { // Step 1: get presigned URL const presignRes = await fetch('/api/uploads/presign', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ filename: file.name, contentType: file.type, size: file.size, }), }); if (!presignRes.ok) { const { error } = await presignRes.json(); throw new Error(error); } const { url, key } = await presignRes.json(); // Step 2: upload directly to S3 const uploadRes = await fetch(url, { method: 'PUT', headers: { 'Content-Type': file.type }, body: file, }); if (!uploadRes.ok) { throw new Error('S3 upload failed'); } return key; // store this in your database }
The fetch PUT to S3 does not hit your server at all. For large files you can track upload progress using XMLHttpRequest with the upload.onprogress event, which fetch does not yet support natively.
Direct browser-to-S3 uploads require a permissive CORS policy on the bucket. Set this through the S3 console or Terraform:
json[ { "AllowedHeaders": ["Content-Type", "Content-Length"], "AllowedMethods": ["PUT"], "AllowedOrigins": ["https://your-domain.com"], "ExposeHeaders": ["ETag"] } ]
In development, add http://localhost:3000 to AllowedOrigins. Never use "*" in production — it allows any origin to upload to your bucket.
The server validates file type and size before issuing the URL, but the content-type header the client sends to S3 is client-controlled. A malicious client could still rename a script to .jpg. For truly untrusted uploads, validate the actual file content after upload:
typescript// app/api/uploads/confirm/route.ts import { GetObjectCommand } from '@aws-sdk/client-s3'; import { fileTypeFromStream } from 'file-type'; // npm install file-type export async function POST(req: NextRequest) { const { key } = await req.json(); const obj = await s3.send(new GetObjectCommand({ Bucket: process.env.S3_BUCKET_NAME!, Key: key, })); const detectedType = await fileTypeFromStream(obj.Body as ReadableStream); if (!detectedType || !ALLOWED_TYPES.has(detectedType.mime)) { // delete the object from S3 immediately await s3.send(new DeleteObjectCommand({ Bucket: process.env.S3_BUCKET_NAME!, Key: key })); return NextResponse.json({ error: 'Invalid file content' }, { status: 400 }); } // save key to database, return public URL or CDN URL return NextResponse.json({ key }); }
For public files, construct the public URL: https://<bucket>.s3.<region>.amazonaws.com/<key>. For private files, generate a presigned GET URL the same way you generated the PUT URL, with an appropriate expiry.
For production use, put CloudFront in front of S3. It reduces latency for readers globally and avoids S3 egress charges for popular assets. The bucket can then be fully private, with CloudFront as the only allowed accessor via an Origin Access Control.
Your AWS credentials (AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY) must never be prefixed with NEXT_PUBLIC_. The presign endpoint runs entirely server-side. If you need a refresher on the env var rules, see Environment Variables and Secrets Management in Next.js.
Presigned S3 uploads offload bandwidth from your application server, sidestep serverless payload limits, and scale to arbitrarily large files without code changes. The server's role shrinks to issuing signed permissions and confirming results — a small, auditable surface. Add content-type validation both pre- and post-upload, lock down CORS, and serve files through CloudFront in production.
For more on building secure backends, see how I structure large Next.js + Node.js projects or browse the projects page.