How to Build an MLB Player Stats Dashboard

Learn how to build an MLB player stats dashboard using the Highlightly Baseball API. Access batting, pitching, and per-season statistics for MLB and NCAA players.

MLB player stats dashboard

Baseball has always been a sport driven by numbers. From batting averages to earned run averages, statistics tell the story behind every player on the field. If you've ever wanted to create your own player stats dashboard, you're in the right place.

In this tutorial, we're going to walk through building an MLB player stats dashboard from scratch using the Highlightly Baseball API. By the end, you'll have a working application that can search for any MLB or NCAA player, display their profile, break down their batting and pitching stats season by season, and even compare two players side by side.

No advanced experience is required. If you can write a basic fetch request and render some HTML, you're good to go.

Prerequisites

Let's get the boring but important stuff out of the way first.

You'll need an API key from Highlightly. Head over to the login page and create an account, or sign up through RapidAPI if you prefer that platform. Both give you access to the same data, but the main difference is that Highlightly's direct platform offers custom plans and long-term discounts that RapidAPI doesn't.

Once you have your key, every API request needs this header:

          x-rapidapi-key: YOUR_API_KEY
        

If you're going through RapidAPI, you'll also need:

          x-rapidapi-host: mlb-college-baseball-api.p.rapidapi.com
        

The base URL for all requests through Highlightly is https://baseball.highlightly.net. Through RapidAPI, it's https://mlb-college-baseball-api.p.rapidapi.com.

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 the project itself, you can use whatever frontend framework you like. The code examples in this tutorial use vanilla JavaScript to keep things framework-agnostic. However, everything can be translated directly to React, Vue, Svelte, or anything else you're comfortable with.

Searching for Players and Displaying Profiles

The first thing any stats dashboard needs is a way to find players. The Baseball API gives us two endpoints for this:

  • search across all players
  • pull up the full profile for a specific player

Let's start with the search. The GET /players endpoint accepts a name query parameter, along with limit and offset for pagination:

          const API_BASE = "https://baseball.highlightly.net";
const API_KEY = "YOUR_API_KEY";

async function searchPlayers(name) {
  const response = await fetch(
    `${API_BASE}/players?name=${encodeURIComponent(name)}&limit=10`,
    {
      headers: { "x-rapidapi-key": API_KEY }
    }
  );

  const result = await response.json();

  return result.data;
}
        

This returns a paginated list of matching players. Each result includes an id, fullName, and a logo URL (the player's headshot image when available). A nice touch here is adding a debounce to your search input so you're not firing off a request on every single keystroke. Waiting 300–400ms after the user stops typing is usually the sweet spot.

Once the user picks a player from the search results, we can fetch their full profile using GET /players/{id}:

          async function getPlayerProfile(playerId) {
  const response = await fetch(
    `${API_BASE}/players/${playerId}`,
    {
      headers: { "x-rapidapi-key": API_KEY }
    }
  );

  const data = await response.json();

  // Single player object
  return data[0];
}
        

The profile response is rich with information. Here's what you get back inside the profile object and how you might render it:

          function renderPlayerCard(player) {
  const { profile } = player;

  return `
    <div class="player-card">
      <img src="${player.logo}" alt="${player.fullName}" />
      <h2>${profile.fullName}</h2>
      <p class="position">
        ${profile.position.main}
        ${profile.position.abbreviation
          ? `(${profile.position.abbreviation})`
          : ""}
      </p>
      <ul>
        <li><strong>Team:</strong> ${profile.team.displayName}</li>
        <li><strong>Born:</strong> ${profile.birthDate} — ${profile.birthPlace}</li>
        <li><strong>Height:</strong> ${profile.height}</li>
        <li><strong>Weight:</strong> ${profile.weight}</li>
        <li><strong>Jersey:</strong> #${profile.jersey}</li>
        <li><strong>Status:</strong> ${profile.isActive ? "Active" : "Inactive"}</li>
      </ul>
    </div>
  `;
}
        

The team object nested inside the profile also includes the team's logo URL and league field (either "MLB" or "NCAA"), so you can display the team badge right alongside the player's info. It's a small detail that makes the dashboard feel polished.

Batting and Pitching Statistics

Now for the part everyone's really here for. The GET /players/{id}/statistics endpoint returns a player's performance data broken down by season:

          async function getPlayerStats(playerId) {
  const response = await fetch(
    `${API_BASE}/players/${playerId}/statistics`,
    {
      headers: { "x-rapidapi-key": API_KEY }
    }
  );

  const data = await response.json();

  return data[0];
}
        

The response organises stats inside a perSeason array. Each entry represents one season and includes the league, season year, associated team(s), and a seasonBreakdown field that's either "Entire" (regular season plus post-season combined) or "Season" (regular season only).

The stats themselves live in a stats array where each item has a name, value, and category, where category is either "Batting" or "Pitching". This is the key distinction you'll use to decide which table to show.

Here's how to separate and render them:

          function renderSeasonStats(seasonEntry) {
  const battingStats = seasonEntry.stats.filter(
    (s) => s.category === "Batting"
  );
  const pitchingStats = seasonEntry.stats.filter(
    (s) => s.category === "Pitching"
  );

  let html = `
    <h3>
      ${seasonEntry.season} ${seasonEntry.league}
      <span class="breakdown">(${seasonEntry.seasonBreakdown})</span>
    </h3>
  `;

  if (battingStats.length > 0) {
    html += `<h4>Batting</h4><table><tr>`;
    battingStats.forEach((s) => {
      html += `<th>${s.name}</th>`;
    });
    html += `</tr><tr>`;
    battingStats.forEach((s) => {
      html += `<td>${s.value}</td>`;
    });
    html += `</tr></table>`;
  }

  if (pitchingStats.length > 0) {
    html += `<h4>Pitching</h4><table><tr>`;
    pitchingStats.forEach((s) => {
      html += `<th>${s.name}</th>`;
    });
    html += `</tr><tr>`;
    pitchingStats.forEach((s) => {
      html += `<td>${s.value}</td>`;
    });
    html += `</tr></table>`;
  }

  return html;
}
        

For batters, the most important stats to surface prominently are At-Bats (AB), Hits (H), Home Runs (HR), Runs Batted In (RBI), Batting Average (AVG), On-Base Percentage (OBP), and Slugging Percentage (SLG). For pitchers, prioritise Innings Pitched (IP), Strikeouts (SO), Earned Run Average (ERA), Walks Allowed (BB), and Total Pitches Thrown.

A nice UX pattern is to add a dropdown or toggle that lets users switch between the "Entire" and "Season" breakdowns. Most casual fans want the full picture, but analytically-minded users will appreciate being able to isolate regular season performance from post-season.

Visualising Career Trends

A table of numbers is useful. A chart that shows how those numbers have changed over a player's career is compelling.

Since the perSeason array gives us data across multiple years, we can map it directly to a time-series chart. Let's say we want to plot home runs per season for a batter:

          function prepareChartData(playerStats) {
  return playerStats.perSeason
    .filter((s) => s.seasonBreakdown === "Season") // Regular season only
    .map((season) => {
      const hr = season.stats.find(
        (s) => s.name === "Home Runs" && s.category === "Batting"
      );
      return {
        season: season.season,
        team: season.teams?.[0]?.displayName || "Unknown",
        homeRuns: hr ? hr.value : 0
      };
    })
    .sort((a, b) => a.season - b.season);
}
        

If you're using a charting library like Recharts (great for React) or Chart.js (works everywhere), you can feed this data straight in. Here's a quick Chart.js example:

          function renderCareerChart(chartData, canvasId) {
  const ctx = document.getElementById(canvasId).getContext("2d");

  new Chart(ctx, {
    type: "bar",
    data: {
      labels: chartData.map((d) => `${d.season}`),
      datasets: [
        {
          label: "Home Runs",
          data: chartData.map((d) => d.homeRuns),
          backgroundColor: "#1d4ed8"
        }
      ]
    },
    options: {
      responsive: true,
      plugins: {
        tooltip: {
          callbacks: {
            afterLabel: (context) =>
              `Team: ${chartData[context.dataIndex].team}`
          }
        }
      }
    }
  });
}
        

You can easily extend this pattern to chart ERA over time for pitchers, or create a multi-line chart that overlays batting average and slugging percentage. The data is all there in the same perSeason array. It's just a matter of picking which stat names to extract.

One thing to keep in mind: each season entry can reference multiple teams if a player was traded mid-season. The teams array in the response handles this, so your chart tooltips should account for that possibility.

Match-Day Performance with Box Scores

Career stats tell the macro story, but sometimes you want to zoom in on a single game. The GET /box-scores/{matchId} endpoint gives you per-player performance data for a specific match.

The response structure is an array of two objects where each contains the team info and a boxScores array listing every player and their individual stats:

          async function getBoxScore(matchId) {
  const response = await fetch(
    `${API_BASE}/box-scores/${matchId}`,
    {
      headers: { "x-rapidapi-key": API_KEY }
    }
  );

  // Returns [{ team: {...}, boxScores: [...] }, { team: {...}, boxScores: [...] }]
  return await response.json();
}
        

Each player in the box score has a statistics array following the same pattern: objects with name, group (either "Batting" or "Pitching"), and value. So the rendering logic you already built for season stats applies here too.

To make this useful in a dashboard context, you'll want to pair it with the GET /matches/{matchId} endpoint, which gives you the full match context that includes the opposing team, final score per inning, venue, weather forecast, and even the umpires. This lets you build a game performance card that reads something like "vs. New York Yankees at Yankee Stadium - 2 for 4, 1 HR, 3 RBI" alongside the final line score.

Here's how you might pull that together:

          async function getRecentGameContext(matchId, playerId) {
  const [boxScoreData, matchData] = await Promise.all([
    getBoxScore(matchId),
    fetch(`${API_BASE}/matches/${matchId}`, {
      headers: { "x-rapidapi-key": API_KEY }
    }).then((res) => res.json())
  ]);

  // Find which team the player belongs to
  const allPlayers = [
    ...boxScoreData[0].boxScores.map((p) => ({
      ...p,
      teamName: boxScoreData[0].team.name
    })),
    ...boxScoreData[1].boxScores.map((p) => ({
      ...p,
      teamName: boxScoreData[1].team.name
    }))
  ];

  const playerBox = allPlayers.find((p) => p.player.id === playerId);
  const match = matchData[0];

  return { playerBox, match };
}
        

This gives you everything needed to render a rich game card. The match data even includes venue (city, stadium name, state) and forecast (weather status and temperature), which are great details to include if you want to go the extra mile.

Comparing Two Players Side by Side

A side-by-side comparison is one of those features that takes a dashboard from "useful" to "share-worthy." Fantasy baseball drafts, trade debates, MVP discussions. People love putting two players next to each other.

The good news is you've already built all the pieces. You just need to run the search and stats logic twice and stitch the results together:

          async function comparePlayers(playerIdA, playerIdB, season) {
  const [statsA, statsB] = await Promise.all([
    getPlayerStats(playerIdA),
    getPlayerStats(playerIdB)
  ]);

  // Find the requested season for each player
  const seasonA = statsA.perSeason.find(
    (s) => s.season === season && s.seasonBreakdown === "Season"
  );
  const seasonB = statsB.perSeason.find(
    (s) => s.season === season && s.seasonBreakdown === "Season"
  );

  // Build a unified list of stat names
  const allStatNames = [
    ...new Set([
      ...(seasonA?.stats || []).map((s) => s.name),
      ...(seasonB?.stats || []).map((s) => s.name)
    ])
  ];

  return allStatNames.map((name) => {
    const valA = seasonA?.stats.find((s) => s.name === name)?.value ?? "—";
    const valB = seasonB?.stats.find((s) => s.name === name)?.value ?? "—";
    return { stat: name, playerA: valA, playerB: valB };
  });
}
        

When rendering the comparison table, a simple but effective visual touch is to highlight the leading value in each row. A green shade or bold text on whichever number is better (higher for hits, lower for ERA) immediately draws the eye to who has the edge. Just keep in mind that "better" depends on the stat. More strikeouts is good for a pitcher but bad for a batter, so you'll want a small lookup that knows which direction is favourable for each metric.

Here's a straightforward way to handle that:

          // Stats where a LOWER value is better
const LOWER_IS_BETTER = [
  "Earned Run Average",
  "Total Strikeouts", // batting context
  "Hits Allowed",
  "Runs Allowed",
  "Total Earned Runs",
  "Total Walks Allowed",
  "Home Runs Allowed"
];

function getLeader(statName, valA, valB, category) {
  if (valA === "—" || valB === "—") return null;

  const lowerBetter =
    LOWER_IS_BETTER.includes(statName) ||
    (statName === "Total Strikeouts" && category === "Batting");

  if (lowerBetter) return valA < valB ? "A" : valB < valA ? "B" : null;
  return valA > valB ? "A" : valB > valA ? "B" : null;
}
        

Handling Errors and Edge Cases

Before you ship anything, there are a few gotchas worth addressing.

API errors follow a straightforward pattern. A 400 response means something was wrong with your request, usually a missing or invalid parameter. A 500 means something went wrong on the server side. In both cases, wrap your fetch calls in try-catch blocks and show the user a friendly fallback message rather than a blank screen.

          async function safeFetch(url) {
  try {
    const response = await fetch(url, {
      headers: { "x-rapidapi-key": API_KEY }
    });
    if (!response.ok) {
      console.error(`API error: ${response.status}`);

      return null;
    }

    return await response.json();
  } catch (error) {
    console.error("Network error:", error);
    return null;
  }
}
        

Two-way players are a real consideration in modern baseball. A player like Shohei Ohtani will have both batting and pitching stats in the same season entry. Your dashboard should render both tables when they're present rather than assuming it's one or the other. The filtering logic we wrote earlier already handles this. If both battingStats and pitchingStats arrays have entries, both sections render.

Missing data can happen for a few reasons. A player might not have stats for a given season (rookie, injured, or just hasn't played yet). Some NCAA players have sparser data than MLB veterans. Always check for empty arrays and null values before trying to render, and show a "No stats available for this season" message rather than crashing.

NCAA vs MLB responses follow the same structure, so you don't need separate rendering logic. The main differences are cosmetic. The league field will say "NCAA" instead of "MLB", and team names use college display names (like "Florida State") rather than city-based names. Your UI should work seamlessly across both.

Putting It All Together

Let's step back and look at how all these pieces connect into a cohesive application.

The architecture is simple. A search bar at the top feeds into a player profile card, which feeds into tabbed content areas for season stats, career charts, and recent game box scores. A second search slot enables the comparison view.

Here's a suggested folder structure:

          mlb-dashboard/
├── index.html
├── styles.css
├── js/
│   ├── api.js            # All fetch functions (search, profile, stats, box scores)
│   ├── playerCard.js     # Profile rendering
│   ├── statsTable.js     # Season stats tables (batting & pitching)
│   ├── careerChart.js    # Chart.js career visualisation
│   ├── boxScore.js       # Match-day performance card
│   ├── comparison.js     # Side-by-side comparison logic & rendering
│   └── app.js            # Main app controller, event listeners, routing
└── assets/
    └── placeholder.png   # Fallback player image
        

To get started, a sample GitHub project can be found here.

A few performance tips to keep things snappy. First, cache player profiles and stats after the initial fetch. If a user clicks between tabs, there's no reason to re-request data you already have. A simple in-memory object keyed by player ID does the job. Second, use the limit parameter wisely. When searching players, limit=10 is plenty for a dropdown. When pulling stats, the default response includes everything, so you won't need pagination there. Third, watch your remaining API requests via the x-ratelimit-requests-remaining header and consider showing a subtle indicator if the user is burning through calls quickly during a development session.

And don't forget internal linking. If you're building a broader sports platform, the same Highlightly account gives you access to match schedules (GET /matches), league standings (GET /standings), team-level statistics (GET /teams/statistics/{id}), and even video highlights (GET /highlights). Each of those could be a new tab or page that deepens the user experience.

What's Next

You've now got a working MLB player stats dashboard that covers player search and profiles, detailed batting and pitching breakdowns, career trend charts, single-game box score performance, and head-to-head player comparisons.

From here, there are plenty of directions to take it. You could add lineup data using the GET /lineups/{matchId} endpoint to show starting rosters before games. You could pull in team-level stats to build a team analytics section. Or you could integrate highlights to let users watch key moments from a player's recent games right inside the dashboard.

The full Baseball API documentation covers all 15+ endpoints with detailed response schemas and example payloads. If you're looking to expand beyond baseball, Highlightly also covers NBA, NFL, NHL, football, cricket, and several other sports under the same Sports API.

If you're still deciding which API is the right fit for your project, check out our Best Sport APIs in 2026 comparison. And if you're also working with basketball data, take a look at Building an NBA and NCAA Player App with a Single API since the patterns overlap more than you'd expect.

Happy building.