Dimitrije Stasic
← Back to work

DormSy

A campus marketplace for Luther College: a place to buy and sell the furniture, appliances, and dorm gear that changes hands every time students move in and out. It's a Next.js frontend on top of a small Node.js/Express API, with Supabase for Postgres, authentication, and file storage, styled with Tailwind CSS. I built it solo over about two months and 76 commits, from an empty repo to a live marketplace at getdormsy.com.

DormSy landing page: a marketplace just for your campus, live at Luther College
DormSy's landing page: the verified, .edu-only campus marketplace, now live at Luther College.

The idea

At the end of the year, my senior friends were moving out and trying to sell their stuff: furniture, mini-fridges, desk lamps, things they'd bought new and used for a year or two. There was no good way to reach the people who actually wanted it. The Facebook groups were dead, the group chats were noise, and the timing window was only a few days. Most of it ended up thrown out. Expensive, usable things, in a dumpster.

I'm a junior. Next year that's me. And if it happened to my friends, it's happening to every graduating class, every May, at every college, while first-years arrive in August buying the same things new.

The problem isn't that students don't want to buy or sell. It's that supply and demand at a college are separated by four months, and no channel connects them.

The problem

The gap is really about timing. Supply peaks in May, when leases end and seniors leave. Demand peaks in August, when new students arrive. The two never overlap, so even though nearly everyone would take the deal, there's no moment when a buyer and a seller are in the same place at the same time, and no channel that holds a listing across those four months, somewhere a May seller can post and an August buyer can still find it.

Today that market is a patchwork of channels, and none of them hold. The campus Facebook "Free & For Sale" group has no search that works, no filters, and a memory measured in hours; posts scroll away, it mixes in townspeople instead of staying campus-only, and it lives on a platform a lot of students no longer use. GroupMe and class chats are worse for browsing: a listing is gone the moment three other messages land, with no categories and no way to see what's for sale right now. Instagram stories only reach people who already follow the seller and vanish in a day. And word of mouth down a dorm hallway never reaches the person two buildings over who actually wants your mini-fridge.

A couch gets dropped in the Free & For Sale group at 11pm and it's gone by morning; if you weren't scrolling at the right minute, you missed it. There was never a place where everything for sale simply lived, and where a buyer could look when they were ready instead of hoping to catch it in a feed.

Architecture & technical decisions

DormSy is a Next.js App Router frontend talking to a small Node.js/Express API, with Supabase underneath for Postgres, authentication, and file storage. The Express service runs on Railway and the frontend deploys separately.

The frontend and API deploy separately; Supabase backs data, auth, and uploaded photos.
getdormsy.com/feed
DormSy listings feed with search, category filters, and item cards
The listings feed: search, category filters, and per-item cards, each tied to a verified campus seller.

The interesting parts of a project like this are the decisions you can't tell were decisions from the outside. These are the ones I'd defend, and what I'd reconsider with more time.

Supabase for auth and data instead of rolling my own

Why
As a solo dev on a fixed launch timeline, I needed auth, a Postgres database, file storage, and realtime, and I wanted to spend the time on features, not infrastructure. Supabase gives all four behind one SDK with real Postgres underneath, so I kept SQL, foreign keys, and relational thinking. I rejected Firebase because a NoSQL document model fights a marketplace's inherently relational shape (listings, photos, users, colleges), and rolling my own auth was never on the table: email verification, session refresh, and password reset are exactly what you get wrong at 2am and leak accounts over.
Tradeoff
Lock-in to Supabase's auth and SSR cookie model, and that model bit me. Its SSR client chunks the session token across multiple cookies (`sb-…-auth-token.0`, `.1`), which broke my proxy's auth check, and the PKCE versus token-hash email flow caused the cross-device verification bug below.
What I’d reconsider
Row-Level Security. I enforced the "every query filters by `college_id`" rule in the backend because it was faster to reason about, but a single missing filter is a cross-college data leak. At real scale I'd push that invariant into Postgres RLS policies so the database enforces isolation even when app code forgets.

A separate Express API alongside Next.js

Why
Two reasons. The Supabase service-role key must never reach the browser bundle, and a standalone backend makes that a physical boundary: the key lives only on Railway and can't leak into client code. And I wanted the API to be frontend-agnostic, so a future React Native app could reuse the same REST endpoints. The alternative was folding everything into Next.js route handlers on Vercel: one deploy, less config.
Tradeoff
Two deploys, two bills, CORS, and a network hop between Vercel and Railway. I also had to solve problems the single-app approach gets for free, like the proxy-based auth guard and rate-limiting behind a proxy, both of which cost me commits.
What I’d reconsider
This is the one I'd genuinely revisit. For a solo dev at 30 users, a separate backend was premature; route handlers keep the service key server-side just fine, without the second deploy, second bill, and CORS. I'd start there today and extract a service only when a real need appeared.

Deploying the backend on Railway

Why
Railway deploys a plain Node/Express app from the repo with near-zero config and gives a persistent, always-on process, which suited background-ish work like listing-expiry warnings and message email notifications better than per-request functions. I passed on Vercel Functions for the backend to avoid cold starts and execution limits on a long-lived REST service, and on a raw VPS to avoid hand-managing nginx, TLS, and deploys as a solo dev.
Tradeoff
Another vendor and another bill, plus a real gotcha: rate limiting was silently broken because Express behind Railway's proxy saw every request as the proxy's IP, until I set `trust proxy`. A fully managed serverless platform would have hidden that from me.
What I’d reconsider
If I folded the backend into Next.js route handlers, Railway disappears and everything runs on one Vercel deploy. Short of that, I'd re-evaluate Fly.io for multi-region only if users ever spread out geographically; at one college in one town, Railway was the right amount of effort for the stage.

If I built DormSy again, I'd start with Next.js route handlers instead of a separate Express backend. The standalone API kept my Supabase service key safely server-side, but route handlers do that too, without a second deploy, a second bill, and the CORS and proxy bugs I spent days on. I split the backend to stay scalable for a mobile app that didn't exist yet. I was solving a problem I didn't have.

Problems I hit

Two of the three hardest bugs never threw an error. Nothing crashed: the behavior was just quietly wrong, which made them far harder to find than anything that failed loudly.

iPhone photos wouldn't upload (HEIC across browsers)

Problem

Students on iPhones couldn't post listings. iOS saves camera photos as .heic, which browsers can't display or upload directly, and phone photos were the primary way listings get created, so this broke the main path into the product. It was roughly eight commits over two days.

Diagnosis

No single approach worked everywhere. Backend conversion with sharp failed in the deploy environment. heic2any (WASM) works on Chrome and Firefox but needed CSP changes (worker-src blob: and unsafe-eval) to run its web workers, and it fell over on Safari and iOS, the exact devices generating the files.

Fix

A two-strategy client-side converter, convertHeic.ts. It tries heic2any first for Chrome and Firefox, then falls back to a <canvas> re-encode, which works on Safari and iOS because iOS renders HEIC natively. Both paths output JPEG before upload, with a clean error only if both fail.

getdormsy.com/sign-up
DormSy sign-up form gated to a verified .edu college email
Sign-up is gated to a verified .edu email. The cross-device verification bug below lived in this flow.

Email verification broke across devices

Problem

Users signed up on a laptop, opened the confirmation email on their phone, and verification failed.

Diagnosis

The callback only handled Supabase's PKCE code flow, which requires the code_verifier stored in the originating browser. Open the link anywhere else and there's no verifier, so it fails. Signing up on one device and checking email on another is normal behavior, not an edge case.

Fix

The callback (auth/callback/route.ts) now handles both flows: a token_hash and type flow via verifyOtp, which needs no verifier and works cross-device, alongside the existing PKCE path, sharing one handleSession helper.

Aside

Session cookies also weren't being detected. Supabase SSR splits large auth tokens into chunks (sb-…-auth-token.0, .1), and the proxy guard matched with endsWith("-auth-token") instead of includes(). All told, auth took more than ten fix commits to get right: PKCE versus token-hash, the chunked cookies, the proxy guard, and a profile-creation retry. Logging in is never just logging in.

Rate limiting was silently ineffective behind the proxy

Problem

The Express rate limiter wasn't limiting per user. It looked configured correctly and appeared to work.

Diagnosis

Behind Railway's reverse proxy, every request carried the proxy's IP, so the limiter keyed every user into one shared bucket. Nothing errored: the protection just wasn't there.

Fix

One line: app.set("trust proxy", 1) in server.js, so Express reads the real client IP from X-Forwarded-For. The size of the change isn't the point: the failure was silent, and finding it meant understanding how proxies and X-Forwarded-For actually interact.

Going to market

So far, DormSy is live at a single college, Luther, with around 30 users. I got there through outreach to campus offices: the Center for Sustainable Communities, International Admissions, Student Senate, and the student newspaper. The sustainability coordinator had already created an account before we even met.

Growth was constrained by two things. First, thin listings: an empty marketplace gives nobody a reason to come back. Second, passive marketing: announcements don't create users; they just tell people something exists.

What I'd do differently is the actual lesson here. A marketplace needs seeded supply before it's worth visiting, and it needs in-person distribution, not announcements. The cold-start problem isn't solved by telling more people about an empty store. It's solved by making sure the first people who show up find something worth taking, and by putting the product in front of them where they already are.

What's next

  • Row-Level Security first. The whole promise is "campus-only," but that boundary lives in app code today, where one forgotten filter leaks listings across colleges. Move the invariant into Postgres so the database enforces it.
  • Turn the time gap into the product. Sellers (graduating seniors in May) and buyers (incoming students in August) are four months apart, so a live feed is empty when each group shows up. A "reserve for August" flow lets seniors list at move-out, incoming students claim now, and DormSy coordinates the scheduled handoff.
  • Go deep at Luther before going wide. Seed supply before demand by listing real inventory during May move-out so the first buyers don't hit an empty feed, lean on in-person distribution (RA partnerships, move-out tabling), and only add a second college once the playbook has worked once.