Why I built /drop without touching Vercel's body limit
I wanted a simple public file drop on elangodev.com — share zip archives, screenshots, or lab exports without wiring full user accounts. Proxying uploads through Next.js API routes hits serverless payload limits and burns function duration on bytes I do not need to inspect byte-by-byte. Direct client-to-S3 uploads with presigned URLs were the obvious architecture.
All upload logic lives in app/api/drop/route.js. The browser never sees AWS_ACCESS_KEY_ID. It receives short-lived signed URLs, PUTs files straight to S3, then stores the public CDN URL returned by my CloudFront or media base path.
S3 client setup on the server
getS3Client reads credentials from environment variables with fallbacks for AWS_KEY_ID naming inconsistencies I accumulated across projects. Region defaults to ap-southeast-2 where my bucket lives. Keeping client construction in one function simplifies rotation when I update IAM keys in Vercel.
function getS3Client() {
return new S3Client({
region: process.env.AWS_REGION || "ap-southeast-2",
credentials: {
accessKeyId: process.env.AWS_KEY_ID.trim(),
secretAccessKey: process.env.AWS_SECRET_KEY.trim(),
},
});
}Small files: batch presigned PUT
For files under my size threshold, POST without a special action signs PutObjectCommand URLs. Each file gets a unique key under publicdrop/{timestamp}_{random}/filename sanitized to alphanumeric dots and dashes. That sanitization blocks path traversal in user-supplied names.
const command = new PutObjectCommand({
Bucket: bucketName,
Key: key,
ContentType: type || "application/octet-stream",
});
const uploadUrl = await getSignedUrl(s3, command, { expiresIn: 3600 });
return { uploadUrl, finalUrl: `${baseUrl}/${key}`, key };expiresIn: 3600 gives uploaders an hour — enough for slow mobile networks without leaving windows open for days. The client PUTs to uploadUrl with the matching Content-Type header S3 expects. After success, finalUrl is what I show in the UI and persist if needed.
Large files: multipart with signed parts
When a file exceeds comfortable single PUT size, the client sends action: INIT_MULTIPART. The server creates a multipart upload, returns uploadId and key, then signs each part with SIGN_PART. COMPLETE_MULTIPART sorts parts by PartNumber before calling CompleteMultipartUploadCommand. ABORT_MULTIPART cleans up failed attempts.
Multipart keeps RAM flat on phones uploading large screen recordings from elangodev.com tutorials. I log completion errors with requestId metadata — debugging S3 signature mismatches without that detail wastes evenings.
if (body.action === "SIGN_PART") {
const command = new UploadPartCommand({
Bucket: bucketName,
Key: key,
UploadId: uploadId,
PartNumber: partNumber,
});
const url = await getSignedUrl(s3, command, { expiresIn: 3600 });
return NextResponse.json({ url });
}Listing and deleting safely
GET lists objects under publicdrop/ prefix, maps keys to URLs using AWS_MEDIA_BASE_URL, sorts by lastModified descending. DELETE requires key to start with publicdrop/ — otherwise 401 Unauthorized. That prefix guard stops arbitrary object deletion if someone replays API calls with crafted bodies.
The bucket itself remains private; only presigned PUT grants temporary write to specific keys. Public read happens through my CDN domain configured on the bucket policy for publicdrop/* if I choose — or through signed GET for stricter setups. elangodev.com uses stable media URLs for shareable drop links.
Environment and IAM least privilege
My Vercel project stores AWS_KEY_ID, AWS_SECRET_KEY, AWS_BUCKET_NAME, and AWS_MEDIA_BASE_URL as encrypted env vars available only to production and preview runtimes — never NEXT_PUBLIC. The IAM user behind those keys can PutObject, ListBucket, and DeleteObject only on the publicdrop prefix, not on resume PDFs or private assets in sibling prefixes. If a presigned URL leaks, blast radius stays confined to anonymous drop files I can lifecycle-delete.
I trim credentials on read because trailing newline characters in copied secrets caused signature errors that took hours to diagnose. getS3Client centralizes that defensive trim so every handler benefits.
Validation I enforce before signing
I reject missing names and files over 500 MB in the batch signer — return { error: "Invalid file" } per item instead of failing the whole batch silently. Content-Type comes from the client but I default to application/octet-stream when absent. For stricter deployments I would maintain an allowlist of MIME types for images and pdf only.
Rate limiting is not in route.js today — CloudFront and WAF sit in front for production hardening. Personal sites still benefit from prefix isolation and short presign TTL as baseline security.
Why not upload to Supabase Storage
Supabase Storage would integrate neatly with auth.users. I already had AWS media hosting for resume PDFs and lab assets, so drop reused existing buckets and billing familiarity. The presigned URL pattern transfers to Supabase signed upload URLs if I consolidate later — the security model is identical: server trusts identity, client uploads directly to object storage.
Developer experience on elangodev.com
From the UI perspective, users drag files, see progress per chunk, copy HTTPS links. Failures surface as toast errors with retry on abort. Because Vercel functions only sign strings — not stream bodies — cold starts stay fast and my invoice predictable.
Secure S3 uploads in Next.js come down to never exposing IAM secrets, scoping keys with prefixes, expiring presignatures quickly, and using multipart for scale. The /drop route on elangodev.com is the reference implementation I point to when clients ask how to move files without choking serverless APIs — practical, inspectable, and deployed on my own domain.
When debugging failed uploads, I correlate browser network tabs — PUT status codes to S3 — with CloudWatch request metrics and the console.log lines in route.js that record INIT_MULTIPART and COMPLETE_MULTIPART actions. That triangle of evidence usually reveals expired presigns, wrong Content-Type headers, or parts uploaded out of order. Treating drop as production infrastructure, not a weekend hack, keeps my media pipeline trustworthy for elangodev.com exports and client deliverables alike.



