/blog/Mar 28, 2026

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.

next.jss3typescript

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 emails

The 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.