Create your own MLB Prediction Dashboard

Learn how to build a real-time MLB predictions dashboard using the Highlightly Baseball API. Plot win probabilities over time with Chart.js, compare bookmaker odds, and track live prediction shifts during games.

MLB predictions dashboard

Baseball has always been a numbers game. No sport generates more data per game than baseball. Every pitch has a velocity, a spin rate, a location. Multiply that by 2,430 regular season games, and you start to understand why prediction models love this sport.

Most of that prediction data lives behind enterprise paywalls. Providers such as Sportradar and StatsPerform charge five and six figures for access. That's great if you're ESPN. Not so great if you're a developer who wants to build something cool on a Saturday afternoon.

Highlightly Baseball API provides real-time MLB and NCAA predictions with actual probability percentages, updated every ten minutes during live games. The entire API is accessible through a REST interface and even comes with a free tier.

In this tutorial, we're going to build a complete MLB predictions dashboard from scratch. Our aim is to make a real, working application that fetches live prediction data, plots probability changes over time with Chart.js, compares bookmaker odds, shows team form and head-to-head history, and auto-polls during live games. By the end, you'll have something that looks and feels like a professional sports analytics tool.

You don't need to be a data scientist or a React expert. If you can write a fetch request and read some JSONs, you'll be fine. We'll use vanilla JavaScript with React loaded from a CDN (no webpack and no build step).

Understanding Prediction Data

Before we write any code, let's look at what the API actually gives us. When you fetch a match detail from GET /matches/{id}, the response includes a predictions object that looks like this:

          {
  "predictions": {
    "prematch": [
      {
        "type": "prematch",
        "modelType": "two-way",
        "description": "Yankees is most likely to win the game against Red Sox.",
        "generatedAt": "2026-03-18T04:51:57.121Z",
        "probabilities": {
          "home": "57.91%",
          "away": "42.09%"
        }
      },
      ...
    ],
    "live": [
      {
        "type": "live",
        "modelType": "two-way",
        "description": "Yankees is most likely to win the game against Red Sox.",
        "generatedAt": "2026-03-18T17:11:17.274Z",
        "probabilities": {
          "home": "55.73%",
          "away": "44.27%"
        }
      },
      {
        "type": "live",
        "modelType": "two-way",
        "description": "Yankees is most likely to win the game against Red Sox.",
        "generatedAt": "2026-03-18T17:21:24.311Z",
        "probabilities": {
          "home": "59.42%",
          "away": "40.58%"
        }
      },
      ...
    ]
  }
}
        

There are two key things to notice here.

First, predictions are split into prematch and live arrays. Prematch entries are generated 7 days before the game starts and represent the model's baseline view. Live predictions arrive every ten minutes or so during the game, updating as the situation changes. Each entry has a generatedAt timestamp, so you get a full time series.

Second, the probabilities come as percentage strings like "57.91%". You'll need to parse those into numbers, but the format is clean and consistent.

The description field is a natural language summary from the AI model. It's useful for displaying a human-readable insight alongside the numbers. The modelType tells you the prediction model used. It can be either two-way which means home win or away win, or three-way which includes a draw as well.

This structure is what makes the timeline chart possible. We don't need to store anything on our end. The API hands us the full history of probability changes for every live game.

Project Setup and Authentication

We're going to keep this simple with no webpack and no build step. The project is an Express server that serves a public/ folder of static files. React, Babel, and Chart.js are loaded from CDNs.

Here's the project structure:

          mlb-predictions/
├── server.js                   # Express static server
├── package.json
├── public/
│   ├── index.html              # CDN scripts + module loading
│   ├── css/styles.css          # Theme and animations
│   └── js/
│       ├── api/                # API layer (4 files)
│       ├── parsers/            # Response parsers (5 files)
│       ├── components/         # React components (9 files)
│       ├── utils/              # Helpers (3 files)
│       └── app.jsx             # Root component
        

The server is a few lines of code:

          const express = require('express');
const path = require('path');

const PORT = process.env.PORT || 3000;
const app = express();

app.use(express.static(path.join(__dirname, 'public')));

app.listen(PORT, () => {
  console.log(`  Running at: http://localhost:${PORT}\n`);
  console.log(`  Powered by the Highlightly Baseball API`);
  console.log(`  Docs: https://highlightly.net/documentation/baseball/\n`);
});
        

That's the entire backend. Everything else happens in the browser.

Since Babel standalone doesn't support import/export across files, we use a shared namespace. Every module registers itself on window.MLB:

          window.MLB = window.MLB || {};
MLB.api = MLB.api || {};

MLB.api.BASE = 'https://baseball.highlightly.net';

MLB.api.fetch = async function (path, apiKey) {
  const response = await fetch(`${MLB.api.BASE}${path}`, {
    headers: { 'x-rapidapi-key': apiKey },
  });

  if (response.status === 401 || response.status === 403) {
    throw new Error('AUTH');
  }

  if (response.status === 429) {
    throw new Error('RATE_LIMIT');
  }

  if (!response.ok) {
    throw new Error(`HTTP_${response.status}`);
  }

  return response.json();
};
        

The x-rapidapi-key header is the only authentication you need unless you are going with the RapidAPI platform. For the Highlightly platform, you can get your key from highlightly.net by creating a free account.

Every API response includes x-ratelimit-requests-remaining in its headers. This tells you how many requests you have left before hitting your daily limit. It's a good habit to log this during development, so you don't burn through your quota while testing.

For key validation, we hit GET /teams?league=MLB when the user connects. It's a lightweight call that confirms the key works without wasting quota on a heavy endpoint.

Fetching Today's Games

The match list is the foundation of everything. One call to GET /matches?date=2025-04-15&limit=100 gives you baseball games scheduled for that date. Later on you will need to update this function so that it supports pagination as well.

          MLB.api.fetchMatches = function (date, apiKey) {
  return MLB.api
    .fetch(`/matches?date=${date}&limit=100`, apiKey)
    .then((response) => response?.data || []);
};
        

Each match in the response has this shape:

          {
  "id": 527959,
  "league": "MLB",
  "season": 2025,
  "state": {
    "score": {
      "away": {
        "hits": 7,
        "errors": 0,
        "innings": [0, 0, 0, 0, 0, 1, 0, 1, 0]
      },
      "home": {
        "hits": 6,
        "errors": 0,
        "innings": [0, 0, 0, 0, 0, 0, 0, 0, 0]
      },
      "current": "0 - 2"
    },
    "report": "Final",
    "description": "Finished"
  },
  "homeTeam": {
    "id": 10291193,
    "displayName": "St. Louis Cardinals",
    "name": "Cardinals",
    "logo": "https://highlightly.net/baseball/images/teams/10291193.png",
    "abbreviation": "STL"
  },
  "awayTeam": {
    "id": 10291181,
    "displayName": "Houston Astros",
    "name": "Astros",
    "logo": "https://highlightly.net/baseball/images/teams/10291181.png",
    "abbreviation": "HOU"
  },
  "date": "2025-04-15T23:45:00.000Z",
  "round": "regular-season"
}
        

A few things to watch for when parsing this.

The game status lives in state.description. The possible values are "Scheduled", "In Progress", "Finished", "Postponed", "Canceled", "Suspended", "Rain Delay", "Half Time", "Period End", and "Unknown". For our UI, we collapse these into four buckets:

          MLB.parsers.parseStatus = function (match) {
  const desc = (match.state?.description || '').toLowerCase();

  if (desc.includes('progress') || desc.includes('half time') ||
      desc.includes('rain delay') || desc.includes('period end')) {
    return 'live';
  }

  if (desc.includes('finish') || desc.includes('final')) {
    return 'finished';
  }

  if (desc.includes('postponed') || desc.includes('canceled') ||
      desc.includes('abandoned') || desc.includes('suspended')) {
    return 'postponed';
  }

  return 'scheduled';
};
        

The score is a string like "5 - 3" inside state.score.current. We split on the dash and parse to integers. The innings array tells us how far along the game is. Its length is the number of innings played.

We sort the match list so live games appear first, then upcoming followed by finished games. This way the most interesting content is always at the top. The date picker in the header lets users navigate to any date. During the MLB offseason (November through March), most dates will return empty. Stick to late March through October for the full experience.

Rendering Match Cards

Each game gets its own card. The collapsed view shows you everything you need. This includes the two teams with their logos, the score (if the game has started), the league badge, venue name, and start time.

Team logos come directly from the API's logo field. But logos can fail to load. Maybe the URL changed, or it's a newly added NCAA team. So we build a TeamLogo component that tries the image first and falls back to the team abbreviation in a styled box:

          MLB.components.TeamLogo = function TeamLogo({ team }) {
  const [broken, setBroken] = React.useState(false);

  if (team.logo && !broken) {
    return React.createElement('img', {
      src: team.logo,
      alt: team.name,
      onError: () => setBroken(true),
      style: {
        width: 40,
        height: 40,
        borderRadius: 10,
        objectFit: 'contain',
        background: 'var(--border)',
      },
    });
  }

  return React.createElement('div', {
    style: {
      width: 40,
      height: 40,
      borderRadius: 10,
      display: 'flex',
      alignItems: 'center',
      justifyContent: 'center',
      fontSize: 12,
      fontWeight: 800,
    },
  }, team.abbr);
};
        

This kind of defensive rendering matters more than you'd think. It's the difference between a polished app and one that shows broken image icons when the API data isn't perfect.

For live games, we show a pulsing green "LIVE" badge in the top-right corner. Finished games get a grey "FINAL" badge using the state.report field. Postponed games show "PPD" in yellow. These small touches make the interface instantly scannable.

Parsing and Displaying Predictions

Let's get to the most interesting part.

The challenge is that probability values come as strings like "57.91%". We wrote a dedicated parser for this:

          MLB.parsers.parseProbStr = function (value) {
  if (typeof value === 'number') {
    return value > 0 && value <= 1
      ? Math.round(value * 100)
      : Math.round(value);
  }

  if (typeof value === 'string') {
    const num = parseFloat(value);
    if (Number.isNaN(num)) return 0;

    // "55.73%" is already a percentage, just round it
    if (value.includes('%')) return Math.round(num);

    // 0.5573 is fractional, multiply up
    return num > 0 && num <= 1
      ? Math.round(num * 100)
      : Math.round(num);
  }

  return 0;
};
        

The main prediction parser, parseAllPreds, reads both the prematch and live arrays, extracts the latest entry from each, and builds the full liveHistory array for the chart:

          // Returns: { prematch, live, primary, liveHistory[] }
MLB.parsers.parseAllPreds = function (detail) {
  const predictions = detail?.predictions;
  if (!predictions) return null;

  const { parseSinglePred, parseProbStr } = MLB.parsers;

  const toArray = (val) => (Array.isArray(val) ? val : val ? [val] : []);
  const preArr = toArray(predictions.prematch);
  const liveArr = toArray(predictions.live);

  // Take the latest entry from each array
  const prematch = preArr.length
    ? parseSinglePred(preArr[preArr.length - 1])
    : null;

  let live = null;
  let liveHistory = [];

  if (liveArr.length) {
    live = parseSinglePred(liveArr[liveArr.length - 1]);

    liveHistory = liveArr
      .map((entry) => {
        const probs = entry.probabilities || {};
        return {
          home: parseProbStr(probs.home || 0),
          away: parseProbStr(probs.away || 0),
          generatedAt: entry.generatedAt || null,
        };
      })
      .filter((point) => point.home || point.away);
  }

  if (!prematch && !live) return null;

  return {
    prematch,
    live,
    primary: live || prematch,  // Live takes priority
    liveHistory,
  };
};
        

The primary field is the key decision. When a live prediction exists, it will show it. When it doesn't, it will fall back to prematch. This "live-first" rule applies everywhere through the app (the collapsed card bar, the expanded panel, and the AI insight text).

For the probability bar, we use different color schemes to visually distinguish prediction types. Prematch predictions render in green (home) and red (away). Live predictions use cyan and orange. This way, even at a glance, you can tell whether you're looking at a pre-game assessment or an in-game update.

Building the Probability Timeline Chart

The API's liveHistory array is a perfect time series. Each entry has a timestamp and home/away percentages. Now we need to plot them.

We use Chart.js loaded from CDN. No npm install and tree-shaking worries. One <script> tag and we have a full charting library.

The chart renders inside a PredictionChart React component. It takes the history array and team abbreviations as props. Here's the core dataset logic:

          // Prematch datasets is dashed baseline when live data exists
if (history.some((h) => h.homePre != null)) {
  const makePreDataset = (label, data, color, rgbaFill) => ({
    label,
    data,
    borderColor: solid ? color : `${color}66`,
    backgroundColor: solid ? rgbaFill : 'transparent',
    borderDash: solid ? [] : [6, 4],
    borderWidth: solid ? 2.5 : 2,
    pointRadius: solid ? 3 : 0,
    tension: 0.35,
    spanGaps: true,
  });

  datasets.push(
    makePreDataset(`${hAbbr} Pre`, history.map((h) => h.homePre), '#22C55E', 'rgba(34,197,94,.08)'),
    makePreDataset(`${aAbbr} Pre`, history.map((h) => h.awayPre), '#EF4444', 'rgba(239,68,68,.06)'),
  );
}

// Live datasets is solid and bold
if (history.some((h) => h.homeLive != null)) {
  const makeLiveDataset = (label, data, color, rgbaFill) => ({
    label,
    data,
    borderColor: color,
    backgroundColor: rgbaFill,
    borderWidth: 2.5,
    pointRadius: 3,
    tension: 0.35,
    fill: true,
    spanGaps: true,
  });

  datasets.push(
    makeLiveDataset(`${hAbbr} Live`, history.map((h) => h.homeLive), '#06B6D4', 'rgba(6,182,212,.08)'),
    makeLiveDataset(`${aAbbr} Live`, history.map((h) => h.awayLive), '#F97316', 'rgba(249,115,22,.06)'),
  );
}
        

The visual result is intuitive. For a game in progress, you see the prematch values as flat dashed lines (the baseline) and the live values as solid lines that move up and down as the game unfolds. The cyan line is the home team's win probability, and the orange line is the away team's. As one goes up, the other goes down and they always sum to 100%.

We don't build the history by polling. The API already provides every live snapshot in the predictions.live array. When the user expands a card, we fetch GET /matches/{id} once and the entire history is there:

          const recordHistory = useCallback((preds) => {
  if (!preds) return;
  const points = [];

  // Prematch baseline as the first data point
  if (preds.prematch?.hasNumeric) {
    const label = preds.prematch.generatedAt
      ? fmtShort(preds.prematch.generatedAt)
      : 'Pre';

    points.push({
      label,
      homePre: preds.prematch.home,
      awayPre: preds.prematch.away,
      homeLive: null,
      awayLive: null,
    });
  }

  // Each live snapshot becomes a chart data point
  for (const snapshot of preds.liveHistory || []) {
    points.push({
      label: snapshot.generatedAt ? fmtShort(snapshot.generatedAt) : 'Live',
      homePre: preds.prematch?.hasNumeric ? preds.prematch.home : null,
      awayPre: preds.prematch?.hasNumeric ? preds.prematch.away : null,
      homeLive: snapshot.home,
      awayLive: snapshot.away,
    });
  }

  points.sort((a, b) => a.ts - b.ts);
  setPredHist(points);
}, []);
        

For live games, we also set up a polling interval. Every 60 seconds, we re-fetch the match detail and rebuild the chart. New live snapshots that the API has generated since the last poll automatically appear as new data points on the line.

When both prematch and live predictions have numeric values, the expanded panel also shows a side-by-side comparison. There are two cards, one for each type, with their own probability bars. Below that, a shift indicator tells you how much the home team's probability has changed since the pre-game assessment. Something like "NYY shifted +7% since prematch" gives the user an instant sense of game momentum.

Team Form and Head-to-Head context

Predictions in isolation are interesting. Predictions with context are useful.

The Highlightly Baseball API gives us two endpoints that add exactly the context we need.

GET /last-five-games?teamId={id} returns the last five finished games for a specific team. We parse each game's state.score.current field relative to the queried team ID to determine whether it was a win, loss, or draw:

          MLB.parsers.parseFormArr = function (data, teamId) {
  if (!Array.isArray(data)) return [];

  return data.slice(0, 5).map((match) => {
    const scoreStr = match.state?.score?.current;
    if (!scoreStr) return 'D';

    const [homeStr, awayStr] = `${scoreStr}`.split(/\s*-\s*/);
    const homeScore = parseInt(homeStr, 10) || 0;
    const awayScore = parseInt(awayStr, 10) || 0;
    const isHome = match.homeTeam?.id === teamId;
    const teamScore = isHome ? homeScore : awayScore;
    const oppScore = isHome ? awayScore : homeScore;

    if (teamScore > oppScore) return 'W';
    if (teamScore < oppScore) return 'L';
    return 'D';
  });
};
        

The result is a simple array like ["W", "W", "L", "W", "L"]. The FormBadge component renders these as compact colored boxes. Green for wins, red for losses. Placed side by side for both teams on the prediction card, it tells you instantly who's hot and who's struggling.

GET /head-2-head?teamIdOne={id}&teamIdTwo={id} returns up to ten past meetings between two specific teams. We count wins per side and extract the scores and dates. The H2HBar component shows this as a proportional split bar. If the home team has won 6 of the last 10, the bar is 60% green and 40% yellow. Below the bar, the individual game scores appear as compact chips with date labels.

Both of these endpoints are fetched in parallel with the match detail when the user expands a card. We use Promise.allSettled so that if one call fails the rest of the card still renders perfectly.

Integrating Bookmaker Odds

The odds endpoint rounds out the prediction story. Where the AI model says "Yankees at 58%", the bookmakers might say "Yankees at 1.65 decimal odds". When those numbers agree, the prediction feels solid. When they diverge, that's where it gets interesting.

GET /odds?matchId={id}&limit=5 returns odds from multiple bookmakers. The response structure looks like this:

          {
  "data": [
    {
      "matchId": 394,
      "odds": [
        {
          "bookmakerName": "DraftKings",
          "type": "prematch",
          "market": "Home/Away",
          "values": [
            { "odd": 1.65, "value": "Home" },
            { "odd": 2.25, "value": "Away" }
          ]
        },
        {
          "market": "Over/Under 8.5",
          "values": [
            { "odd": 1.91, "value": "Over" },
            { "odd": 1.91, "value": "Under" }
          ]
        }
      ]
    }
  ]
}
        

The markets available include Home/Away (moneyline), Over/Under (totals), Asian Handicap, and Odd or Even. For our dashboard, we focus on Home/Away and Over/Under since those are the most relevant to predictions.

Parsing requires grouping by bookmaker name, then extracting the Home and Away values from the Home/Away market:

          for (const odds of allOdds) {
  const name = odds.bookmakerName || 'Bookmaker';
  const market = (odds.market || '').toLowerCase();
  const values = odds.values || [];

  if (!byBookmaker[name]) {
    byBookmaker[name] = {
      bookmaker: name, homeML: null, awayML: null, overUnder: null,
    };
  }

  const book = byBookmaker[name];

  if (market.includes('home/away')) {
    for (const v of values) {
      const side = (v.value || '').toLowerCase();
      if (side === 'home') book.homeML = v.odd;
      if (side === 'away') book.awayML = v.odd;
    }
  }

  if (market.includes('over/under')) {
    const lineMatch = odds.market.match(/[\d.]+/);
    if (lineMatch) book.overUnder = parseFloat(lineMatch[0]);
  }
}
        

The OddsTable component renders this as a compact grid with the bookmaker name, home odds, away odds, and the over/under line. Odds are displayed in European decimal format, which is what the API returns.

The odds endpoint requires a paid Highlightly plan. On the free tier, it returns an error. The app handles this gracefully with a subtle notice instead of a broken table. Prematch odds refresh multiple times a day. Live odds refresh every ten minutes. Odds data is available up to 28 days after a game finishes and 7 days before it starts.

Venue, Weather and Match Details

When you call GET /matches/{id} for the detail view, you get more than just predictions. The response includes venue information, a weather forecast, play-by-play data, team rosters, and umpire assignments.

For our dashboard, we use the venue displayed in the card header, and the weather forecast shown in the expanded panel footer. Something like "cloudy · 11.97°C" gives users a quick sense of game day conditions.

We didn't use the rest of the detail data in this tutorial, but it's worth knowing what's there because these are natural extension points:

  • The plays array contains pitch-by-pitch data with pitch type (CURVE, FASTBALL, etc.), velocity, ball-strike counts, and scoring information.
  • The rosters object has the full lineup for both teams with jersey numbers, positions, and a starter flag.
  • The stats array has team-level batting, pitching, and fielding statistics aggregated for the match.
  • referees tells you who's calling the game.

These are all available from the same single API call you're already making.

Putting It All Together

When a user taps a card, a lot happens in a very short time. Five API calls fire simultaneously using Promise.allSettled:

          const [d, o, hf, af, hh] = await Promise.allSettled([
  fetchDetail(match.id, apiKey),           // Predictions + venue + weather
  fetchOdds(match.id, apiKey),             // Bookmaker odds
  fetchLastFive(home.id, apiKey),          // Home team form
  fetchLastFive(away.id, apiKey),          // Away team form
  fetchH2H(home.id, away.id, apiKey),     // Head-to-head history
]);
        

We use allSettled instead of Promise.all for a deliberate reason: if the odds call fails (you're on a free plan) or the H2H call returns empty (it's a rare inter-league matchup with no history), the other four results still render. Each endpoint failure is isolated.

The match detail endpoint returns an array with a single object.

While the data is loading, shimmer placeholders fill the expanded panel at the exact heights of the sections they'll replace. For live games, after the initial fetch, a polling interval kicks in. Every 60 seconds, we re-fetch the match detail, parse the updated predictions, and rebuild the chart. The user sees the game evolving in near real-time without any manual refresh.

Project Architecture and Code Organization

Let's zoom out and look at how the codebase is structured. The modular organization makes it easy to find, read, and modify any piece of the application.

js/api/ - Four files, one per concern. client.js owns the fetch wrapper, base URL, and key validation. matches.js, odds.js, and teams.js each wrap their respective endpoints. If Highlightly changes their base URL or auth header tomorrow, you change one line in client.js and everything works.

js/parsers/ - Five files that mirror the API layer. predictions.js is the most complex (~100 lines) because it handles the prematch/live structure, percentage string parsing, and live history extraction.

js/components/ - Nine JSX files ranging from 20-line leaf components (TypeBadge, TeamLogo) to the 250-line GameCard which orchestrates the expand flow, polling, and history recording.

js/utils/ - Three files of pure functions which include date helpers, display formatters, and the confidence tier calculator.

Everything communicates through the window.MLB namespace. Components destructure what they need at the top of their function:

          const { parseTeam, parseStatus, parseScore, parseAllPreds } = MLB.parsers;
const { fetchDetail, fetchOdds, fetchLastFive, fetchH2H } = MLB.api;
const { PredictionChart, PredBar, TypeBadge, OddsTable } = MLB.components;
        

Script load order in index.html matters. Utilities load first, then the API layer, then parsers, then components, then the app, then the mount. For production, you'd migrate to Vite or esbuild and get proper ES module imports. But for a tutorial project, the simplicity is worth it.

For the full code, a sample GitHub project can be found here.

Going Further

The dashboard we built covers predictions, odds, form, and head-to-head history. But the Highlightly Baseball API has several more endpoints that are natural extensions:

  • Highlights - GET /highlights?matchId={id} returns video clips with game recaps, live events, post-match interviews. You can embed these directly using the embedUrl field.
  • Standings - GET /standings?leagueType=MLB&year=2026 provides divisional standings with win-loss records. Adding a small badge like "1st in AL East" to each team gives instant context.
  • Box Scores - GET /box-scores/{id} returns per-player batting and pitching statistics for a specific game.
  • Lineups - GET /lineups/{matchId} provides starting lineups a few hours before game time.
  • Player Statistics - GET /players/{id}/statistics gives per-season stats. Combine this with our player stats dashboard tutorial and you've got a serious analytics tool.
  • NCAA Coverage - Everything we built works for college baseball too. The API covers NCAA with the same endpoints - just pass league=NCAA in your match queries.

If you're thinking about taking this to production, consider migrating from Babel standalone to a proper bundler like Vite for faster load times. You could also persist prediction history to a database and build a long-term accuracy tracker.

Wrapping Up

We started with an empty folder and ended with a production-quality predictions dashboard that pulls real-time data from the Highlightly Baseball API. The app displays live win probabilities, plots them over time with Chart.js, compares bookmaker odds from multiple providers, and adds context through team form streaks and head-to-head history.

If you want to explore the API further, the full Baseball API documentation covers every endpoint with response schemas and example payloads. The blog section has more tutorials, including guides on player stats dashboards and monetising sports data.