
FifthYear
March 2026 – present · Live on web, iOS, and Android
Registration, payments, teams, brackets, results, and discovery for adult recreational athletes and the volunteers who run their competitions.
Role
Founder. Product, design, and all engineering.
Stack
FifthYear is a two-sided platform for adults who keep competing after school and the people who organize those competitions: league commissioners, race directors, club presidents, gym owners running a local tournament. One athlete profile spans every sport. One set of organizer tools replaces the spreadsheet, the Venmo requests, and the group chat.
I built it from an empty repo in March 2026. As of September it is a production system with a web app, a native mobile app on both stores, a staff CMS, and a payments stack that moves money to organizers.
The product
Athletes discover events, register (or enter a lottery), form teams, recruit free agents, and follow their results and standings over time. Organizers run the whole lifecycle of an event: registration windows, waitlists, promo codes, lottery draws, brackets and schedules, results import, revenue, broadcasts to registrants, and staffing for volunteers.
The category is fragmented by sport vertical. RunSignup owns races, LeagueApps and TeamSnap own team sports, Eventbrite is generic. FifthYear is the horizontal play: defined by the athlete and the organizer, not the sport. Athletes are free. Organizers pay nothing up front. The platform takes a small percentage of each paid registration, capped per athlete, deducted before payout. It only makes money when organizers do.
Architecture
Two independently deployed services and a mobile app, all talking to one Postgres:
- API: Hono on Bun. Self-hosted auth (bcrypt, 1-hour access JWTs, 30-day rotating refresh tokens, email verification, MFA for payout-enabled accounts). Tagged-template SQL through
postgres.js, no ORM. Migrations are plain SQL files applied by the API on boot. - Web: React 19, Vite, Tailwind v4, React Query for server state, Zustand for auth. A monochrome editorial theme built on CSS custom properties.
- Mobile: Expo Router and React Native, shipped to the App Store and Google Play through EAS.
- Internal: a separate staff API and CMS for publishing legal documents, help articles, the changelog, and a 333-row feature matrix that is the shared source of truth for what has shipped.
There is deliberately no monorepo workspace. Shared validators and types are copied into each app’s own src/shared/, and a verify script asserts that the mirrored modules stay byte-identical across all three apps and then runs the same fixtures through each app’s own copy of zod, because identical source can still behave differently under separately installed dependencies. That check has caught real drift more than once.
Money
Payments run on Stripe Connect with the organizer as the merchant of record. Registration fees flow directly to the organizer’s connected account; the platform fee is taken via transfer_data.amount rather than application_fee_amount, because the two are mutually exclusive and only the former lets the organizer be the merchant.
Organizers choose who pays fees. In absorb mode the athlete pays the listed price and both the platform fee and Stripe’s processing fee come out of the payout. In pass_to_registrant mode the charge is grossed up so the payout equals the listed price exactly:
T = ceil((price + platformFee + 30) / 0.971)
Not price + fees. Stripe’s 2.9% applies to the amount charged, so simply adding the fees shorts the organizer by 2.9% of the fees on every entry. One function computes the breakdown and the organizer’s net is taken as the residual so that net, platform fee, and processing fee sum to the charge to the cent. Refunds return the organizer’s net, clamped to the listed price; neither fee is refundable in either mode.
Concurrency without a coordinator
Two schedulers run inside the API process: a five-minute tick that draws lotteries whose draw time has passed, and a fifteen-minute tick that sends 24-hour reminder emails. Instead of a Redis lock or a job queue, idempotency is enforced at the row: the lottery draw takes SELECT ... FOR UPDATE on the event’s drawn flag, and the reminder writes reminder_sent_at atomically. First-come-first-served registration on a capped event serializes concurrent registrants the same way, inside a transaction, which is what closed an overbooking bug found in pre-launch QA.
Operating it
- Errors are captured first-party into a grouping queue with a CLI and an HQ view. Sentry was removed in August 2026 and is not planned.
- Migrations are tracked by full filename, not number, so two branches can both add a
076_*.sqland both will run. A pre-tool hook refuses a reused number and refusesCREATE TABLE IF NOT EXISTSon a table an earlier migration already created, because that is a silent no-op that reports success. That exact no-op is how signup once shipped recording no consent. - Email moved from Resend to Cloudflare in September 2026, with inbound mail handled by a worker so organizers can reply to registrants from their inbox.
- Documentation is written to rot slowly on purpose: the handoff document refuses to list what shipped, because
git logis accurate by construction and a hand-maintained table is not.
How it was built
Every line went through Claude Code, with me as product manager, reviewer, and the person who decides. The repo carries the working agreement in CLAUDE.md: scope discipline, what needs permission, how migrations are numbered, which modules are mirrored. Hooks enforce the parts that a document cannot. Work happens in git worktrees so several branches can be in flight without stepping on each other, and a pre-push verify runs both test suites, both builds, the shared-mirror check, and the zod parity check.
The interesting part of that workflow was not writing code faster. It was that the constraints I would normally hold in my head as a PM (who is the merchant of record, what happens to a registrant when a lottery is redrawn, which fees are refundable) had to be written down precisely enough for an agent to act on them. The codebase is the spec.
What is still open
The public launch is gated on decisions, not engineering: publishing terms and privacy policies through the CMS, settling a business mailing address, and confirming which pre-existing payout-enabled accounts need forced MFA enrollment. An August 2026 launch audit found the rate limiter keyed on the wrong hop of X-Forwarded-For, so 200 login attempts from one client produced zero throttles; that fix is in. The athlete waitlist still has no admin read path, which matters because the launch plan is “athletes join a list, then you invite the list.”