Back to path
IntermediateInvoiceDesk · Project 7 of 12 ~5h· 5 milestones

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.

Presigned URLsS3-compatible storageClient-side uploadsMIME allowlistingBullMQ repeatable jobsDrizzle schema designFile integrity checksBucket hygiene

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:

lib/storage.tstypescript
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. 1

    Presign uploads with the S3 SDK

    6 guided steps

    Presigning 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. 2

    PUT straight from the browser with progress

    6 guided steps

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

    Record metadata and serve privately

    5 guided steps

    A 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. 4

    Validate like you mean it

    5 guided steps

    A 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. 5

    Clean up orphans

    5 guided steps

    A 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

4 starter files, ready to clone
5 guided milestones
5 full reference solutions
9 code blocks explained line-by-line
5 "is it working?" checks
6 interview questions it prepares you for

You'll walk away with

Org logo uploads directly from the browser to MinIO and appears on generated invoice PDFs
Receipts can be attached to an invoice and downloaded via a short-lived presigned GET
files table records every confirmed upload with orgId, kind, key, contentType, sizeBytes
Disallowed MIME types and oversized files are rejected before a URL is ever signed
A HEAD check after every upload catches a mismatch between claimed and stored file
A nightly BullMQ job removes bucket objects with no matching confirmed row

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