Stop routing file uploads through your server
Presigned S3 PUT URLs for the warranty form — the file bytes never touch Next.js, and the UX is better for it.
For the Patrik warranty form I needed customers to upload up to four photos per
claim. The obvious first implementation — multipart/form-data to a Next.js
route handler — works fine until you think about what "works fine" actually
costs: memory pressure, request timeouts on slow connections, and the server
sitting idle waiting for bytes it doesn't need to see.
Presigned PUT URLs fix all of this.
The flow
Browser → POST /api/upload-url → server generates presigned URL → returns { url, key }
Browser → PUT <presigned URL> directly to S3 (Hetzner in our case)
Browser → POST /api/warranty with { submissionId, fileKeys[] }
Server writes to MongoDB, appends Google Sheet row, sends emailsThe server never handles a file byte. It just authorises the upload (step 1) and records the result (step 3).
Generating the URL
import { S3Client, PutObjectCommand } from '@aws-sdk/client-s3';
import { getSignedUrl } from '@aws-sdk/s3-request-presigner';
const key = `uploads/warranty/${submissionId}/${slot}.${ext}`;
const url = await getSignedUrl(
s3,
new PutObjectCommand({ Bucket: BUCKET, Key: key, ContentType: mime }),
{ expiresIn: 300 },
);Five-minute expiry. The key includes the submission ID so files are grouped per claim and easy to find.
What got better
Upload speed: noticeably faster because the browser talks directly to the object store, not through a Next.js process in a different region.
Timeout behaviour: a slow upload doesn't hold a server connection open.
Memory: zero. The route handler returns before the file lands anywhere.
One thing to watch
The presigned URL authorises a specific Content-Type. Validate the mime type
server-side before issuing the URL — don't let the browser dictate it.
Obvious in retrospect. Everything is.