Aaron Perkel

projects / catamount-sublets

Catamount Sublets

Sublet marketplace for UVM students

Catamount Sublets screenshot
Status
Live
Role
Designed, built, and run it solo
Timeline
Mar 2025 – present (rebuilt Oct 2026)
Stack
Next.js 16, TypeScript, PostgreSQL (Neon), Drizzle ORM, Better Auth, Zod, sharp, MapLibre, Vercel (Blob, Cron), Resend, Vitest, GitHub Actions

Highlights

  • 141 UVM students and 75 listings (Nov 2026), running since 2025.
  • Live migration from PHP on UVM’s Silk server to Next.js on Vercel: ~20 minutes from freeze to open writes, and old links kept working.
  • Replaced CAS single sign-on with email one-time codes restricted to NetID@uvm.edu, so every student maps to exactly one account.

Overview

Catamount Sublets is where UVM students find and post sublets — browse by price, semester, and distance from campus on a grid or a map, and share any listing with a public link. It’s the second version. The first, UVM Sublets, was a PHP and MySQL app I launched in March 2025 on UVM’s Silk web server, behind CAS single sign-on. I rebuilt it as Next.js and TypeScript on Vercel and moved everyone over in a live cutover on October 9, 2026.

How it works

From → to

PHP 8 and MySQL on UVM’s Silk shared hosting, behind CAS → Next.js 16 and TypeScript on Vercel, Neon Postgres with Drizzle, Vercel Blob for photos, and Resend for mail.

Sign-in without CAS

CAS sign-in came from an Apache module on Silk, so leaving Silk meant replacing it. Sign-in is now a one-time email code through Better Auth (hashed, 10-minute expiry, three attempts), sent with Resend. Sign-up is restricted to NetID@uvm.edu: dotted First.Last aliases are refused so every student maps to one account, migrated accounts are matched by NetID, and anyone else needs an admin-approved access request. The domain check runs before any email is sent and again when the user row is created, and a resend cooldown plus per-email and per-IP limits in Postgres-backed buckets sit in front of every code.

The migration script

A tested TypeScript script reads the legacy MySQL — from a dump or over an SSH tunnel — prints every planned insert, update, and delete as a dry run, then applies them and verifies each photo against the original by hash. It honors opt-outs and was rehearsed against a Neon branch before the real run. It moved 44 listings and 199 photos, reusing 196 images already uploaded in rehearsal.

The cutover

Freeze the old site read-only, take a final export, dry-run against production, snapshot a Neon restore branch, apply, open read-only, swap Silk’s document root for a redirect shim, smoke-test, and turn writes on — 07:45 to 08:03 on a Friday morning. Old share links and preview images redirect to the new site, go.uvm.edu/sublet points there until January 2027, and a 48-hour rollback path was ready.

Data model

Users and sessions, listings with their photos, semesters, and an event history, plus access requests and Postgres-backed rate-limit buckets. Six Drizzle migrations.

Photos

Uploaded straight from the browser to a private Blob store, then processed with sharp: auto-rotated, capped at 3000px, EXIF and GPS stripped, and written out as a 1600px WebP, a 600px thumbnail, and a JPEG share card.

Tests and CI

36 Vitest files cover the schema, services, rate limits, the legacy migration and its dump parser, image processing, and the auth allowlist. GitHub Actions runs lint, typecheck, tests, and the build on every push.

Admin

A moderation console: bans, listing status, photo cleanup, semester archives, announcements, a read-only switch, and broadcast email.

What I’d do next

  • Open sign-up to other Vermont schools.
  • Handle Resend bounce webhooks so dead addresses stop getting mail.

Links

← All projects