Tutorials · 16 min read

Building a Fantasy Sports App with Player Stats APIs

A fantasy app is not just a live score app with extra steps. It needs per-player statistics, injury status, projections, and a points engine that scores every play in real time. This guide covers the architecture, data requirements, and the scoring code that ties it together.

Last updated: August 2026

Overview

Fantasy sports apps turn spectators into participants: users draft real players, and those players’ real-world performance determines the user’s score. This creates a hard dependency on player-level data that goes far beyond what a live score app needs. You need passing yards, three-pointers made, saves, cards, and assists — not just the final score.

The architecture challenge is that fantasy points must be recalculated every time a player’s stats change, which happens continuously during live matches. A well-designed fantasy app ingests player stats, applies a scoring ruleset, updates contest standings, and pushes the updated points to every user in the contest — all within seconds.

Key difference from a live score app: A live score app cares about match-level data (the score). A fantasy app cares about player-level data (every stat event). That means 10-50x more data points per match and a real-time scoring engine on top of your ingestion pipeline.

Fantasy App Architecture

The fantasy architecture extends the live score architecture with three new components: a player stats ingester, a points calculation engine, and a contest manager that tracks which users own which players.

┌──────────────┐   ┌───────────────┐   ┌──────────────┐
│ Player Stats  │──▶│ Scoring Engine │──▶│ Contest Mgr  │
│ API (upstream)│   │ (rules engine)  │   │ (rankings)   │
└──────────────┘   └───────────────┘   └──────────────┘
       │                    │                    │
       ▼                    ▼                    ▼
┌────────────┐      ┌────────────┐      ┌──────────────┐
│ Player Cache│      │ Points Cache│     │  WebSocket    │
│ (Redis)     │      │ (Redis ZSET)│     │  Fan-out      │
└────────────┘      └────────────┘      └──────────────┘
       │
       ▼
┌──────────────────┐
│ PostgreSQL        │
│ (players, rosters,│
│  contests, history)│
└──────────────────┘

The scoring engine is the heart of the system. It listens for player stat updates, applies the contest’s scoring ruleset, writes the resulting points to a Redis sorted set (which keeps users ranked by score in real time), and publishes the update to the WebSocket layer.

Player Data Requirements

The data you need depends on the sport, but every fantasy app requires these player-level data streams:

Data StreamWhy It MattersUpdate Frequency
Live player statsCore scoring input (points, yards, goals)Every 10-30 seconds
Lineup confirmationsA benched player scores zeroPre-match + in-game
Injury reportsRemoves a player from scoring eligibilityMinutes to hours
ProjectionsPre-draft baseline expectationsDaily
Season aggregatesForm indicators for draftingPost-match

Injury Reports and Status Tracking

Injury data is make-or-break for fantasy. If a star player is ruled out 30 minutes before kick-off and your app still has them in user lineups scoring zero points, users will churn. You need a real-time injury feed that triggers a lineup-status recalculation.

SportsDataIO and API-Sports both provide injury endpoints. Poll them aggressively in the hour before kick-off — that is when final inactive lists are published. Cache injury status with a 60-second TTL and push updates via WebSocket so users can swap players before the lock deadline.

Live Scoring and Real-Time Points

As live stats flow in, the scoring engine applies the contest’s ruleset to convert raw stats into fantasy points. The result is written to a Redis sorted set per contest, which gives you O(log N) rank updates and lets you stream live leaderboard positions to every user in the contest.

// lib/scoring-engine.js
import Redis from "ioredis";

const redis = new Redis(process.env.REDIS_URL);

// Scoring rulesets are configurable per contest type
const SCORING_RULES = {
  football: {
    goals: 4,        // 4 points per goal
    assists: 3,      // 3 per assist
    clean_sheet: 4,  // 4 for defenders/GK if no goals conceded
    yellow_card: -1, // -1 per yellow
    red_card: -3,    // -3 per red
    minutes_played_bonus: (mins) => (mins >= 60 ? 2 : 0),
  },
  basketball: {
    points: 1,       // 1 fantasy point per real point
    rebound: 1.2,
    assist: 1.5,
    steal: 3,
    block: 3,
    turnover: -1,
    double_double: 5,
    triple_double: 10,
  },
};

export async function calculatePlayerScore(playerId, sport, stats, contestId) {
  const rules = SCORING_RULES[sport] || SCORING_RULES.football;
  let points = 0;

  for (const [stat, value] of Object.entries(stats)) {
    if (stat in rules && typeof rules[stat] === "number") {
      points += value * rules[stat];
    }
  }

  // Bonus for minutes played (football)
  if (rules.minutes_played_bonus && stats.minutes_played) {
    points += rules.minutes_played_bonus(stats.minutes_played);
  }

  // Double-double / triple-double bonus (basketball)
  if (rules.double_double && sport === "basketball") {
    const cats = ["points", "rebound", "assist", "steal", "block"];
    const doubles = cats.filter((c) => stats[c] >= 10).length;
    if (doubles >= 3) points += rules.triple_double;
    else if (doubles >= 2) points += rules.double_double;
  }

  // Write to the contest's sorted set: member = playerId, score = points
  await redis.zadd(`contest:${contestId}:scores`, points, String(playerId));

  // Publish for WebSocket fan-out
  await redis.publish("fantasy:points", JSON.stringify({
    contestId,
    playerId,
    points: Math.round(points * 100) / 100,
    ts: Date.now(),
  }));

  return points;
}

Database Design

The database stores players, contests, rosters, and historical scoring. Live points live in Redis; only finalised results are persisted to PostgreSQL. For a deeper schema treatment, see our sports database guide.

-- schema.sql — fantasy sports core tables
CREATE TABLE players (
  id           SERIAL PRIMARY KEY,
  api_ref      VARCHAR(100) UNIQUE NOT NULL,
  name         VARCHAR(200) NOT NULL,
  team_api_ref VARCHAR(100),
  position     VARCHAR(20),
  sport        VARCHAR(20) NOT NULL,
  active       BOOLEAN DEFAULT TRUE
);

CREATE TABLE contests (
  id           SERIAL PRIMARY KEY,
  name         VARCHAR(200) NOT NULL,
  sport        VARCHAR(20) NOT NULL,
  entry_fee    DECIMAL(10,2) DEFAULT 0,
  prize_pool   DECIMAL(10,2) DEFAULT 0,
  lock_ts      TIMESTAMPTZ NOT NULL,  -- roster lock deadline
  status       VARCHAR(20) DEFAULT 'drafting', -- drafting|locked|live|final
  created_at   TIMESTAMPTZ DEFAULT NOW()
);

CREATE TABLE rosters (
  id           SERIAL PRIMARY KEY,
  contest_id   INT REFERENCES contests(id),
  user_id      INT NOT NULL,
  player_id    INT REFERENCES players(id),
  captain      BOOLEAN DEFAULT FALSE,
  picked_at    TIMESTAMPTZ DEFAULT NOW(),
  UNIQUE(contest_id, user_id, player_id)
);

CREATE TABLE player_scores (
  id           SERIAL PRIMARY KEY,
  player_id    INT REFERENCES players(id),
  contest_id   INT REFERENCES contests(id),
  final_points DECIMAL(8,2) NOT NULL,
  stats_json   JSONB,
  finalized_at TIMESTAMPTZ DEFAULT NOW(),
  UNIQUE(player_id, contest_id)
);

CREATE INDEX idx_rosters_contest ON rosters(contest_id);
CREATE INDEX idx_rosters_user ON rosters(user_id, contest_id);
CREATE INDEX idx_player_scores_contest ON player_scores(contest_id);

Code Examples

Full Scoring Pipeline (Next.js Route Handler)

This endpoint receives a batch of player stat updates from the ingester, runs them through the scoring engine, updates contest rankings, and returns the updated leaderboard. The WebSocket layer picks up the published events and pushes them to contest rooms.

// app/api/fantasy/score-batch/route.js
import { NextResponse } from "next/server";
import { calculatePlayerScore } from "@/lib/scoring-engine";
import { getContestRoster } from "@/lib/db";

export async function POST(request) {
  const { contestId, sport, updates } = await request.json();
  // updates: [{ playerId, stats: { goals: 1, assists: 0, ... } }, ...]

  const roster = await getContestRoster(contestId);
  const rosterPlayerIds = new Set(rooster.map((r) => r.player_id));

  const results = [];
  for (const { playerId, stats } of updates) {
    // Only score players that are on someone's roster
    if (!rosterPlayerIds.has(playerId)) continue;

    const points = await calculatePlayerScore(
      playerId, sport, stats, contestId
    );
    results.push({ playerId, points });
  }

  // Fetch the updated leaderboard from the Redis sorted set
  // (done in the scoring engine via zadd)

  return NextResponse.json({
    contestId,
    scored: results.length,
    results,
  });
}

Best Practices

Make scoring rules configurable, not hard-coded

Store rulesets in the database or a config file, not in source code. Different contest types (50/50, head-to-head, tournament) use different rules. Configurable rules let you launch new contest types without redeploying the scoring engine.

Use Redis sorted sets for live leaderboards

A sorted set (ZSET) keeps members ranked by score automatically. ZADD updates a player’s score and ZREVRANGE returns the leaderboard in O(log N) — far faster than re-sorting in your application layer on every update.

Lock rosters before scoring starts

Enforce a hard roster lock at the match start time. After the lock, no roster changes are allowed. This prevents users from swapping in players mid-match and keeps scoring deterministic. Store the lock timestamp and validate every roster mutation against it.

Reconcile live points with final stats after the match

Live stat feeds can have corrections after a match ends (a stat officially credited to a different player). Run a reconciliation job that recalculates final points from the authoritative post-match stats and updates the database. Live Redis points are a preview, not the source of truth.

Backfill and cache projections before the draft

Pre-load player projections and season aggregates before draft windows open. Users need this data to make picks, and if your API is slow during a live draft, you lose them. Cache aggressively and refresh projections once daily.

Related Guides

Find the right player stats API

Compare providers by player stats depth, injury data, and projection quality to power your fantasy app.