Handle file uploads the production way
Continues from the last build: Slow work (PDFs, email, reminders) runs in a BullMQ worker with retries and idempotency.
Reminders and PDFs run clean now, the worker retries with idempotency keys and nobody pages you when Resend hiccups.
What you'll build
By the end, orgs can upload a logo that renders into every invoice PDF and attach receipts to invoices, without a single byte of file data passing through the Next.js server, and a worker job cleans up anything that never got confirmed.
See how we teach, before you sign up
You don't just get code dumped on you. Every starter file and every solution is explained line-by-line, in plain English. Here's one real file from this project:
import { S3Client, PutObjectCommand, GetObjectCommand, HeadObjectCommand, ListObjectsV2Command, DeleteObjectCommand } from '@aws-sdk/client-s3';
import { getSignedUrl } from '@aws-sdk/s3-request-presigner';
import { randomUUID } from 'crypto';
const BUCKET = process.env.S3_BUCKET ?? 'invoicedesk';
export const s3 = new S3Client({
endpoint: process.env.S3_ENDPOINT,
region: 'us-east-1',
forcePathStyle: true,
credentials: {
accessKeyId: process.env.S3_ACCESS_KEY!,
secretAccessKey: process.env.S3_SECRET_KEY!,
},
});
const ALLOWED_TYPES = new Set(['image/png', 'image/jpeg', 'application/pdf']);
export function buildKey(orgId: string, filename: string) {
const safeName = filename.replace(/[^a-zA-Z0-9._-]/g, '_').slice(-80);
return `${orgId}/${randomUUID()}-${safeName}`;
}
export async function presignPut(key: string, contentType: string) {
if (!ALLOWED_TYPES.has(contentType)) {
throw new Error(`content type ${contentType} is not allowed`);
}
const command = new PutObjectCommand({ Bucket: BUCKET, Key: key, ContentType: contentType });
return getSignedUrl(s3, command, { expiresIn: 60 });
}
export async function presignGet(key: string) {
const command = new GetObjectCommand({ Bucket: BUCKET, Key: key });
return getSignedUrl(s3, command, { expiresIn: 300 });
}
export async function headObject(key: string) {
return s3.send(new HeadObjectCommand({ Bucket: BUCKET, Key: key }));
}
export async function listBucketKeys(prefix: string) {
const out: string[] = [];
let ContinuationToken: string | undefined;
do {
const res = await s3.send(new ListObjectsV2Command({ Bucket: BUCKET, Prefix: prefix, ContinuationToken }));
(res.Contents ?? []).forEach((obj) => obj.Key && out.push(obj.Key));
ContinuationToken = res.NextContinuationToken;
} while (ContinuationToken);
return out;
}
export async function listBucketObjects(prefix: string) {
const out: { key: string; lastModified?: Date }[] = [];
let ContinuationToken: string | undefined;
do {
const res = await s3.send(new ListObjectsV2Command({ Bucket: BUCKET, Prefix: prefix, ContinuationToken }));
(res.Contents ?? []).forEach((obj) => obj.Key && out.push({ key: obj.Key, lastModified: obj.LastModified }));
ContinuationToken = res.NextContinuationToken;
} while (ContinuationToken);
return out;
}
export async function deleteObject(key: string) {
await s3.send(new DeleteObjectCommand({ Bucket: BUCKET, Key: key }));
}
export { BUCKET };
Reading this file
forcePathStyle: trueMinIO needs path-style addressing since it has no wildcard DNS like real S3 buckets.ALLOWED_TYPES.has(contentType)reject at presign time so you never even hand out a URL for a disallowed type.safeName = filename.replace(/[^a-zA-Z0-9._-]/g, '_')never trust the original filename, it can contain path traversal or script-looking characters.randomUUID()the key is unguessable and unrelated to the filename, so listing by prefix doesn't leak client names.expiresIn: 6060 seconds is enough for a browser to start the PUT immediately, not enough for a leaked URL to be useful later.(res.Contents ?? []).forEach((obj) => obj.Key && out.push({ key: obj.Key, lastModified: obj.LastModified }));the nightly sweep needs LastModified alongside the key, that's what lets it compare each object's age against the orphan cutoff instead of deleting on sight.
Starter file for this rung.
That's 1 of 9 explained code blocks in this single project.
The build, milestone by milestone
- 1
Presign uploads with the S3 SDK
6 guided stepsPresigning keeps the storage credentials on the server forever and keeps file bytes off it entirely. A narrow, expiring URL limits what a leaked link or a slow client can do.
- 2
PUT straight from the browser with progress
6 guided stepsThe whole point of presigning is that file bytes never touch the Next.js process. A round trip through your own server would reintroduce the memory and timeout problem you're trying to avoid.
- 3
Record metadata and serve privately
5 guided stepsA files table is the single source of truth linking Postgres rows to bucket keys. Keeping the bucket private and gating every read through a fresh signed URL means access can be revoked or audited without touching bucket policy.
- 4
Validate like you mean it
5 guided stepsA client can lie about a file's type, size, or name. The only facts you can trust are what MinIO itself reports back after the bytes are actually stored.
- 5
Clean up orphans
5 guided stepsA user can request a presign, get interrupted, or PUT bytes and then close the tab before confirming. Left alone, the bucket accumulates storage nobody can see or bill against, silently growing forever.
What's inside when you start
You'll walk away with
This is portfolio-grade. Build it free.
Sign up to unlock every milestone step-by-step, the code skeletons, full reference solutions, and checkable tasks, with your progress saved as you build.
Start building