NFL and Fantasy Football on LunarCrush

LunarCrush · Head of Design, product owner of the surfaces · Aug 19 to Sep 4, 2026, ongoing · In progress

In three weeks LunarCrush went from a crypto-first product to one with a full NFL category and a Fantasy Football app: an ESPN and Sleeper pipeline into D1, one page per team and player, and a signal that compares sharp attention against crowd heat.

LunarCrush reads what the world says on social media and correlates it with what moves in the markets. For years that has meant crypto, then stocks. In August 2026 we pointed the same machine at the NFL. This entry documents what got built between Aug 19 and Sep 4, 2026: a data pipeline, a category surface, one page per team and player, and a Fantasy Football app that ranks players by the gap between what sharp analysts are watching and what the crowd is chasing. Most of the implementation was delivered by the agents in Hangar; the surfaces, the data model decisions and the review were mine.

The numbers say how much moved in three weeks. 114 tickets touched NFL or Fantasy. 33 new tables in D1. 32 teams and 3.069 players with a stable LunarCrush identity. A 30-minute signal cycle that has been running since Aug 28.

What we set out to do

Joe's thesis was simple: the social graph we already track for coins and stocks also talks about football, constantly, and a fantasy manager makes decisions every week with almost no signal about attention. If we could show which players the sharps are quietly talking about before the crowd notices, that is an edge no other fantasy tool has. The NFL category would be the ground the Fantasy app stands on: teams, players, standings and games as real entities in the product, with social metrics attached.

The design constraint I set at the start: everything is a topic. A team is a topic page, a player is a topic page, and the football data mounts on top of the same surface the rest of the product uses. No parallel app with its own navigation.

The pipeline underneath

How NFL and Fantasy data reaches the product

Nothing in the browser talks to ESPN or Sleeper. Two scheduled ingestions run on GitHub Actions runners, pull from the providers, normalize every entity to a LunarCrush id, and stream the writes into Cloudflare D1 as SQL files applied in chunks. The provider ids survive only as provenance. The NFL ingestion carries teams, rosters, coaches, schedule, standings, injuries, box scores and odds in cadence tiers: live scores every few minutes during games, schedule and standings every few hours, the full catalog daily.

The Fantasy signal is a second job on the same pattern. Every 30 minutes it takes Sleeper's trending adds and drops, looks up each player's LunarCrush topic, and computes the score that the prototype had validated: sharp attention against crowd heat, with the divergence between them as the Edge Score. Each run writes a versioned snapshot, so a player's signal history is a real time series and not a number that overwrites itself.

The read side is small and stable: a dozen endpoints under /api/nfl/* for the sports data and six under /api/nfl/fantasy/* for the signal. Social metrics are never stored on our side. The surfaces join them in the browser by topic, the same way the crypto category does.

The NFL category

The NFL category: Teams tab with record, seed, streak and next game

The category lives where every other category lives, with the same header, the same Insights and Global Metrics cards, and a Fantasy Football card that links across. Below it, four destinations: Teams, Players, Standings, Games.

Teams is a table of the 32 franchises with division, record, seed, streak, point differential and next game, sortable, with favorites and filters by conference and division. Players is the roster catalog with position chips that map ESPN's granular positions onto the groups a fan uses: a kicker is PK in the data and K in the product, the offensive line is OT, G and C, and the filters had to learn that. Standings groups by conference and division. Games is the schedule by week and state, with live, final and scheduled as the only vocabulary the interface knows.

Players, Standings and Games tabs

All four tables run on the canonical table system I had been building for the product: the same header, the same sort affordance, the same sticky geometry, real tables on mobile. The NFL tables were the first surface migrated to it, and the reason the system exists.

One page per team, one per player

San Francisco 49ers: the Social tab of the topic page

The first delivery of the team page was a separate surface at its own route, and it duplicated the topic page that already existed for every team. I reverted the decision on Aug 21: a team is one page, and that page is the topic page. The Social tab is what LunarCrush already did well. The Team tab mounts the football modules on top: season position, schedule, availability, roster, staff and venue. The old route redirects.

The Team tab: season position, schedule, availability, roster, staff and venue

Players follow the same rule. A player page is a topic page with the sports identity in the header and, below the social chart, a Fantasy Football card: sharp attention against crowd heat for this player, Edge Score, this week's matchup, and a signal history line that goes back to the first snapshot. From there the Fantasy Football button opens the app with the player in context.

Jahmyr Gibbs: the player page with the Fantasy signal card and signal history

Fantasy Football

The Attention Board: every tracked player ranked by Edge Score

The app opens on the Attention Board. Every tracked NFL player, ranked by Edge Score, with position, next opponent, ADP, and two bars: sharp attention and crowd heat. Above the table, four cards set the context: anomalies from the Insights engine, a Spotlight on the most talked-about player and team, a 30-day history of the signal, and the top sharps by matches. Filters by position and team, search by player, and a table view that flips into cards on mobile.

Draft Board: ADP against sharp attention, per player

Draft Board is the same data ordered for a draft: ADP as the anchor, sharp attention and crowd heat as the reasons to move a player up or down your list, with rank and position rank on each row. My Team is the manager's roster, built by adding players from the board or importing a Sleeper league; every rostered player shows draft rank, ADP and position rank, plus the injury designation when the report has one.

Sharp Feed: the posts behind the signal, by analyst, team and player

Sharp Feed shows the posts that moved the signal, each one tied to the analyst, the team and the players it names. Sharps is the roster of analysts we track, with role, affiliation and followers, and each has a page with follower change, engagement and recent posts. Teams and League closes the loop: live scores across the slate and the preseason standings, with every team linking back to its topic page.

Sharps and an analyst page

The flows that connect everything

A reader can start anywhere and end anywhere. From the NFL category a team row opens the topic page; the topic page's Fantasy Football button opens the app with that team's players in view; a player in the Attention Board opens the player topic page; the player page's Fantasy card opens the app on that player. Teams and League inside the app links to the same 32 topic pages. The identity that makes this possible is the LunarCrush id on every entity, and one lesson from the first week: the teams API returns topic names with spaces, so every link had to slugify through the same function or the topic page loaded without its Team tab.

The reserved slots are part of the design. Game pages hold a Fantasy pregame card, an Odds card fed from ESPN's pick center when it exists, and a dashed placeholder for prediction markets, so the surfaces can grow without a redesign.

What broke and what it taught

The social data for NFL entities did not work for any of them for the first ten days, and nobody saw it. Four layers each sufficient on their own: a fetch without the authorization header whose catch returned null in silence; players queried on the topic endpoint with a keyword; a status function that decided "keyword only" before looking at the metrics; and a card gated behind a flag that was false on the canonical route. A catch that returns null is what kept it invisible for the whole project.

The ingestion failed three times in silence on Aug 27 and 28. The monitor that should have caught it never ran once, because scheduled workflows only register from the main branch and it lived on webapp-dev. The telemetry row that recorded each run traveled inside the same SQL file that failed, so a rejected chunk rolled back the record of its own failure. And on Aug 31 an agent's fix chunked ids in groups of 200 against a D1 limit of 100 bound parameters per statement, and every hourly and full tier failed until I found the offset in the error message. Each of those is now a rule in the knowledge base the agents read before touching the pipeline.

What comes next

Joe asked on Sep 3 for league import beyond Sleeper. The research is done: Yahoo is the authenticated candidate, and ESPN, NFL Fantasy, CBS and Fantrax will need a paste-a-link path. Fantasy is still invisible to search engines and LLMs because the no-JS shell is empty; a first pass at server rendering was reverted and is being redone. The NFL table pages on team topics still use pieces of the canonical system rather than the whole of it. And the odds slot waits for the prediction markets that the rest of the product already shows for crypto.

LunarCrush: The Product (2026)

Nicolás Bañados