Case study · Full-stack
Museum of Fantasy Sports
A league history platform that treats a fantasy league's past as an archive worth keeping —
every championship, every blowout, every trade nobody has forgiven.
-
Solo
Design & build
-
Next.js 16
App Router
-
Postgres
via Prisma 7
-
ESPN · Yahoo
Data sources
It runs at /fantasy on this domain rather than on one of its own.
Fantasy platforms are built for the season you are in. Last year's standings are three
clicks deep, the year before that is a dead link, and the trade everyone still argues
about exists only in a group chat nobody can search.
The interesting part of a long-running league is not this week's matchup. It is the
decade of context around it — who has never won, who wins constantly, which rivalry has
a real record behind it and which one is just noise. None of that survives on the
platforms themselves.
So the goal was not another stats dashboard. It was somewhere a league's history is kept
deliberately, and served back in a way that means something to whoever is reading it.
-
01
External platform
ESPN and Yahoo, each behind its own provider with its own authentication story.
-
02
FantasyProvider
One interface per platform. Adding a third source means writing a provider, not touching the app.
-
03
Normalisation
Platform quirks are resolved here — bench slots, placeholder draft picks, differing season shapes.
-
04
Database
One schema, provider-agnostic. Re-importing a season updates in place rather than duplicating it.
-
05
Event engine
Derives records, awards and memories from stored history instead of storing them by hand.
-
06
Personalised UI
The same row reads differently depending on who is signed in.
History is append-safe
Mutable rows carry source and lockedFields, and every
provider-sourced row has a natural key. Re-importing a season updates in place
instead of destroying a manual correction someone made months earlier.
Why it matters: an import that can silently erase human edits is an
import nobody will run twice.
A person is not their team
User → TeamMembership → FantasyTeam → Franchise keeps a manager's
identity separate from whatever they named the team that year.
Why it matters: it is what makes career history traversable. Without
it, a manager who renames their team every season becomes six different people.
Memories are rendered, not written
A memory stores template + data plus a
MemorySubject join, rather than finished prose. One row renders as
"You beat Noah" or "Noah beat you" depending on who is reading it.
Records remember their own lineage
Each record keeps previousRecordId, so the app can say a mark stood for
three years before it fell — which is the part that actually makes a record
interesting.
- FrameworkNext.js 16 · React 19 · TypeScript
- StylingTailwind v4 · Motion
- DataPostgreSQL · Prisma 7
- AuthAuth.js v5, role-based, with an admin surface
- ChartsRecharts
- HostingNetlify, mounted at
/fantasy
The app is built and running: providers, sync, the event engine, auth and the admin
surface all work. It is deployed under this domain rather than its own, which is a
deliberate constraint — one domain, one certificate, one thing to maintain.
Importing real league history is the remaining gate, and it is an access problem rather
than a code one. ESPN needs session cookies for private leagues and prior seasons;
Yahoo needs a registered developer app for OAuth. Both have to come from the league
owner, so the archive fills up as those land.
The live build shows the real interface. It does not present invented league history as
though it were real.