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.
What failed the first week I shipped /drop
My first Vercel deploy signed URLs correctly but browser PUTs returned 403. The root cause was a Content-Type mismatch: the signature included image/png while the client omitted the header on retry after a network blip. I now instruct the /drop UI to always echo the exact Content-Type used when requesting the presign, and I log both values server-side when SIGN_PART or batch PUT fails.
Second failure: keys with spaces and parentheses from macOS screenshots broke CloudFront cache behaviors. Sanitizing to [a-zA-Z0-9._-] before PutObjectCommand eliminated that class of 404 after upload. If you copy this pattern, treat filename sanitization as a security and UX requirement, not a nicety.
Observability I actually use in production
On elangodev.com I care about three metrics: presign latency (p95 of the Next.js route), S3 4xx rate on publicdrop/*, and abandoned multipart uploads older than 24 hours. Abandoned multipart parts cost storage; a nightly lifecycle rule aborts incomplete uploads. When a visitor reports a stuck progress bar, I ask for the browser status code on the PUT — 403 means signature or header drift, 0 means CORS or network, 200 with missing UI update means my client state machine lost the finalUrl.
// Pseudo-check I run after deploy
// 1) POST /api/drop with a tiny fixture → expect uploadUrl
// 2) PUT uploadUrl with matching Content-Type → expect 200
// 3) GET finalUrl → expect 200 from CDNCORS and bucket policy notes for Next.js hosts
Presigned PUT requires the S3 bucket CORS to allow the Origin of elangodev.com (and preview URLs if you test from Vercel previews). AllowedMethods must include PUT and GET; AllowedHeaders should include Content-Type and the x-amz-* headers the SDK may emit. I keep a dedicated publicdrop CORS rule separate from private assets so I never accidentally open write to resume PDFs.
Bucket policy grants s3:GetObject on publicdrop/* to CloudFront OAC only — anonymous internet does not ListBucket. That split (signed write, CDN read) is the model I recommend when clients ask how /drop stays cheap on serverless while remaining shareable.
When I would not use this pattern
If every upload needs server-side virus scanning before the object becomes readable, stream through a worker that scans then copies to a clean prefix. Presigned direct upload is wrong for regulated PII without additional controls. For elangodev.com anonymous lab drops, the blast radius of a bad file is acceptable with size caps and prefix isolation.
Also skip multipart complexity under ~20 MB on desktop — single PUT is simpler to reason about in support tickets. I only enable INIT_MULTIPART above a threshold tuned for mobile screen recordings from my tutorials.
End-to-end walkthrough a reviewer can verify
Open https://elangodev.com/drop (utility page — noindex, but the architecture is documented here). Choose a small PNG, watch the network tab: first a JSON response from /api/drop with uploadUrl and finalUrl, then a PUT to Amazon S3 or your CDN endpoint. That two-step flow is the entire security story — the browser never receives AWS_SECRET_KEY.
Compare that to a naive FormData post to a Route Handler that buffers the file in memory: on Vercel you will hit body size limits and pay for duration proportional to bytes. Presigning flips the cost model so the serverless function only signs strings. I keep this article updated whenever I change thresholds in route.js so the public write-up matches production.
Threat model summary
- Stolen presign: limited to one key prefix for one hour.
- Stolen IAM user: still constrained by prefix IAM policy.
- Malicious filename: sanitized; path traversal rejected.
- DELETE abuse: requires publicdrop/ prefix or 401.
Those four bullets are what I paste into client security reviews. Expanding them into generic cloud theory would dilute the value; grounding them in /drop keeps the page unique on the web.





