Build a World Cup 2026 Live Tracker

Learn how to build a real-time World Cup match tracker with live scores, lineups, match events, and video highlights all from a single API.

Live match tracker

The 2026 World Cup is happening this summer with 48 teams, 104 matches, three countries hosting (US, Canada, Mexico). Now, if you're a developer, this is honestly one of the best chances you'll get to build something that people actually want to use. During the tournament, millions of people will be refreshing tabs and pulling down on their phones looking for scores, goals, and highlights. What if you had your own app serving that up?

That's what we're building today. A live World Cup tracker that pulls in match scores, events (goals, cards, subs), team lineups, match stats and actual video highlights. We're using Highlightly's Football API for all of it, which means one API key, one data source and no duct-taping three different services together. We'll use React on the frontend and a small Node/Express backend to keep the API key safe. If React isn't your thing, the logic works the same in Vue, Svelte, or plain JavaScript. Swap out the components and you're good to go.

One thing to know upfront is that the World Cup 2026 league ID in Highlightly is 1635. That's the ID that will scope every API call to just World Cup matches. You'll see it everywhere in the code below. Since there are no World Cup matches being played yet, we can swap the ID, for testing purposes, to something else such as Serie A with ID 115669.

Project preparation

To get started, we need the following:

  • Sign up at Highlightly. Alternatively, you can create a RapidAPI account, but you will need to swap some endpoints in the upcoming sections. There's a free BASIC tier which can be used for prototyping. For full World Cup access you'll want at least PRO plan, but you can start without it.
  • Node.js v18 or newer. Anything recent will do.
  • Some React experience. You don't need to be an expert, but you should know what useState and useEffect do.
  • npm or yarn whichever you prefer.
  • Optionally, you can create an account on Vercel or Render if you want to deploy the finished app.

Let's set up the project:

          npx create-react-app world-cup-tracker
cd world-cup-tracker
npm install axios
        

And set up a small Express server so we're not exposing our API key in the browser:

          mkdir server && cd server
npm init -y
npm install express axios cors dotenv
        

Next, create a .env file in server/ folder:

          HIGHLIGHTLY_API_KEY=your_api_key_here
        

That's the base setup. Now we can start writing some code.

A Quick Look at the Highlightly Football API

Before we start building components, it helps to understand what we're working with. The base URL for Highlightly's platform is https://soccer.highlightly.net. If you are using the RapidAPI platform, you will need to swap to the other base URL https://football-highlights-api.p.rapidapi.com

Every request needs two headers:

           const headers = {
  "X-RapidAPI-Key": process.env.HIGHLIGHTLY_API_KEY,
  // Only needed for RapidAPI platform
  "X-RapidAPI-Host": "football-highlights-api.p.rapidapi.com",
}; 
        

The data is organized pretty intuitively and goes from leagues → matches → events → highlights. Since we only care about the World Cup, we'll pass leagueId=1635 on basically every call.

Here is a subset of endpoints we'll actually use:

  • GET /matches: Fetch matches by league, date, team, timezone, etc.
  • GET /matches/{matchId}: Get full details for one match.
  • GET /lineups/{matchId}: Get confirmed lineups for a match
  • GET /highlights: Fetch video highlights.
  • GET /leagues: Browse available leagues.

Each endpoint has various query parameters you can use for data filtration. There's also limit and offset for pagination, which you'll need to use. If you've worked with any REST API before, this will feel familiar.

Setting Up the Backend Proxy

We need a thin layer between the browser and Highlightly so the API key stays on the server. This is about 50 lines of Express code. Create a new server/index.js file:

          const express = require("express");
const axios = require("axios");
const cors = require("cors");
require("dotenv").config();

const app = express();
app.use(cors());

const API_BASE = "https://soccer.highlightly.net";
const LEAGUE_ID = "1635"; // World Cup 2026

const headers = {
  "X-RapidAPI-Key": process.env.HIGHLIGHTLY_API_KEY,
  // Only needed for RapidAPI platform
  // "X-RapidAPI-Host": process.env.RAPIDAPI_HOST,
};

// Get matches for a specific date
app.get("/api/matches", async (req, res) => {
  try {
    const { date, timezone = "US/Eastern" } = req.query;
    const response = await axios.get(`${API_BASE}/matches`, {
      headers,
      params: { leagueId: LEAGUE_ID, date, timezone, limit: 50 },
    });
    res.json(response.data);
  } catch (error) {
    console.error("Error fetching matches:", error.message);
    res.status(500).json({ error: "Failed to fetch matches" });
  }
});

// Get details for a single match
app.get("/api/matches/:matchId", async (req, res) => {
  try {
    const response = await axios.get(
      `${API_BASE}/matches/${req.params.matchId}`,
      { headers }
    );
    res.json(response.data);
  } catch (error) {
    console.error("Error fetching match details:", error.message);
    res.status(500).json({ error: "Failed to fetch match details" });
  }
});

// Get lineups for a specific match
app.get("/api/lineups/:matchId", async (req, res) => {
  try {
    const response = await axios.get(
      `${API_BASE}/lineups/${req.params.matchId}`,
      { headers }
    );
    res.json(response.data);
  } catch (error) {
    console.error("Error fetching lineups:", error.message);
    res.status(500).json({ error: "Failed to fetch lineups" });
  }
});

// Get highlights (optionally filtered by matchId)
app.get("/api/highlights", async (req, res) => {
  try {
    const { date, timezone = "Etc/UTC", matchId } = req.query;
    const params = { leagueId: LEAGUE_ID };
    if (date) params.date = date;
    if (timezone) params.timezone = timezone;
    if (matchId) params.matchId = matchId;

    const response = await axios.get(`${API_BASE}/highlights`, {
      headers,
      params,
    });
    res.json(response.data);
  } catch (error) {
    console.error("Error fetching highlights:", error.message);
    res.status(500).json({ error: "Failed to fetch highlights" });
  }
});

const PORT = process.env.PORT || 3001;
app.listen(PORT, () => console.log(`Server running on port ${PORT}`));
        

Run node index.js and you've got a local API at localhost:3001/api/*. The frontend will talk to this instead of hitting Highlightly directly.

Fetching Live World Cup Matches

Time for the fun part. Let's make our first API call.

Create a new src/api.js file. This is a small service layer, so we're not scattering axios calls everywhere.

          import axios from "axios";

const API = axios.create({
  baseURL: process.env.REACT_APP_API_URL || "http://localhost:3001/api",
});

export async function getMatches(date) {
  const { data } = await API.get("/matches", {
    params: { date, timezone: Intl.DateTimeFormat().resolvedOptions().timeZone },
  });

  return data;
}

export async function getMatchDetails(matchId) {
  const { data } = await API.get(`/matches/${matchId}`);

  return data;
}

export async function getLineups(matchId) {
  const { data } = await API.get(`/lineups/${matchId}`);

  return data;
}

export async function getHighlights(params) {
  const { data } = await API.get("/highlights", { params });

  return data;
}
        

A couple of things to notice. We pull the API URL from an environment variable (useful when you deploy later), we detect the user's timezone automatically instead of hardcoding it, and lineups get their own function because they live at a separate endpoint (/lineups/{matchId}) rather than being part of the match details response. Here's roughly what the match object looks like when it comes back. For demonstration purposes, let's showcase a match from Serie A:

          [
    {
        "venue": {
            "city": "Reggio Emilia",
            "name": "Mapei Stadium - Citta del Tricolore",
            "country": "Italy",
            "capacity": "21525"
        },
        "referee": {
            "name": "Marinelli, Livio",
            "nationality": "Italy"
        },
        "forecast": {
            "status": "clear night",
            "temperature": "7.49°C"
        },
        "events": [
            {
                "team": {
                    "id": 416072,
                    "logo": "https://highlightly.net/soccer/images/teams/416072.png",
                    "name": "Sassuolo"
                },
                "time": "17",
                "type": "Yellow Card",
                "assist": null,
                "player": "W. Coulibaly",
                "playerId": 20662572,
                "substituted": null,
                "assistingPlayerId": null
            },
            {
                "team": {
                    "id": 416072,
                    "logo": "https://highlightly.net/soccer/images/teams/416072.png",
                    "name": "Sassuolo"
                },
                "time": "36",
                "type": "Substitution",
                "assist": null,
                "player": "W. Coulibaly",
                "playerId": 20662572,
                "substituted": "F. Romagna",
                "assistingPlayerId": 4919670
            },
            {
                "team": {
                    "id": 429688,
                    "logo": "https://highlightly.net/soccer/images/teams/429688.png",
                    "name": "Verona"
                },
                "time": "39",
                "type": "Yellow Card",
                "assist": null,
                "player": "A. Edmundsson",
                "playerId": 27256971,
                "substituted": null,
                "assistingPlayerId": null
            },
            {
                "team": {
                    "id": 416072,
                    "logo": "https://highlightly.net/soccer/images/teams/416072.png",
                    "name": "Sassuolo"
                },
                "time": "40",
                "type": "Goal",
                "assist": "A. Lauriente",
                "player": "A. Pinamonti",
                "playerId": 5006288,
                "substituted": null,
                "assistingPlayerId": 356769
            },
            {
                "team": {
                    "id": 416072,
                    "logo": "https://highlightly.net/soccer/images/teams/416072.png",
                    "name": "Sassuolo"
                },
                "time": "44",
                "type": "Missed Penalty",
                "assist": "D. Berardi",
                "player": "D. Berardi",
                "playerId": 4916611,
                "substituted": null,
                "assistingPlayerId": 4916611
            },
            {
                "team": {
                    "id": 416072,
                    "logo": "https://highlightly.net/soccer/images/teams/416072.png",
                    "name": "Sassuolo"
                },
                "time": "44",
                "type": "Goal",
                "assist": null,
                "player": "D. Berardi",
                "playerId": 4916611,
                "substituted": null,
                "assistingPlayerId": null
            },
            {
                "team": {
                    "id": 429688,
                    "logo": "https://highlightly.net/soccer/images/teams/429688.png",
                    "name": "Verona"
                },
                "time": "51",
                "type": "Yellow Card",
                "assist": null,
                "player": "Al Musrati",
                "playerId": 6763120,
                "substituted": null,
                "assistingPlayerId": null
            },
            {
                "team": {
                    "id": 416072,
                    "logo": "https://highlightly.net/soccer/images/teams/416072.png",
                    "name": "Sassuolo"
                },
                "time": "62",
                "type": "Goal",
                "assist": "A. Lauriente",
                "player": "D. Berardi",
                "playerId": 4916611,
                "substituted": null,
                "assistingPlayerId": 356769
            },
            {
                "team": {
                    "id": 429688,
                    "logo": "https://highlightly.net/soccer/images/teams/429688.png",
                    "name": "Verona"
                },
                "time": "72",
                "type": "Substitution",
                "assist": null,
                "player": "A. Sarr",
                "playerId": 38149909,
                "substituted": "D. Mosquera",
                "assistingPlayerId": 9566935
            },
            {
                "team": {
                    "id": 416072,
                    "logo": "https://highlightly.net/soccer/images/teams/416072.png",
                    "name": "Sassuolo"
                },
                "time": "72",
                "type": "Substitution",
                "assist": null,
                "player": "A. Pinamonti",
                "playerId": 5006288,
                "substituted": "M. Nzola",
                "assistingPlayerId": 5042352
            },
            {
                "team": {
                    "id": 429688,
                    "logo": "https://highlightly.net/soccer/images/teams/429688.png",
                    "name": "Verona"
                },
                "time": "73",
                "type": "Substitution",
                "assist": null,
                "player": "D. Bradaric",
                "playerId": 2306801,
                "substituted": "D. Oyegoke",
                "assistingPlayerId": 24698842
            },
            {
                "team": {
                    "id": 416072,
                    "logo": "https://highlightly.net/soccer/images/teams/416072.png",
                    "name": "Sassuolo"
                },
                "time": "73",
                "type": "Substitution",
                "assist": null,
                "player": "A. Lauriente",
                "playerId": 356769,
                "substituted": "A. Fadera",
                "assistingPlayerId": 42455693
            },
            {
                "team": {
                    "id": 416072,
                    "logo": "https://highlightly.net/soccer/images/teams/416072.png",
                    "name": "Sassuolo"
                },
                "time": "73",
                "type": "Substitution",
                "assist": null,
                "player": "I. Kone",
                "playerId": 52815560,
                "substituted": "E. Iannoni",
                "assistingPlayerId": 45288005
            },
            {
                "team": {
                    "id": 416072,
                    "logo": "https://highlightly.net/soccer/images/teams/416072.png",
                    "name": "Sassuolo"
                },
                "time": "78",
                "type": "Yellow Card",
                "assist": null,
                "player": "S. Walukiewicz",
                "playerId": 6533856,
                "substituted": null,
                "assistingPlayerId": null
            },
            {
                "team": {
                    "id": 429688,
                    "logo": "https://highlightly.net/soccer/images/teams/429688.png",
                    "name": "Verona"
                },
                "time": "82",
                "type": "Substitution",
                "assist": null,
                "player": "A. Harroui",
                "playerId": 6027511,
                "substituted": "T. Suslov",
                "assistingPlayerId": 31368911
            },
            {
                "team": {
                    "id": 429688,
                    "logo": "https://highlightly.net/soccer/images/teams/429688.png",
                    "name": "Verona"
                },
                "time": "82",
                "type": "Substitution",
                "assist": null,
                "player": "K. Bowie",
                "playerId": 20397244,
                "substituted": "I. Vermesan",
                "assistingPlayerId": 56823494
            },
            {
                "team": {
                    "id": 429688,
                    "logo": "https://highlightly.net/soccer/images/teams/429688.png",
                    "name": "Verona"
                },
                "time": "85",
                "type": "Yellow Card",
                "assist": null,
                "player": "Al Musrati",
                "playerId": 6763120,
                "substituted": null,
                "assistingPlayerId": null
            },
            {
                "team": {
                    "id": 429688,
                    "logo": "https://highlightly.net/soccer/images/teams/429688.png",
                    "name": "Verona"
                },
                "time": "85",
                "type": "Red Card",
                "assist": null,
                "player": "Al Musrati",
                "playerId": 6763120,
                "substituted": null,
                "assistingPlayerId": null
            },
            {
                "team": {
                    "id": 416072,
                    "logo": "https://highlightly.net/soccer/images/teams/416072.png",
                    "name": "Sassuolo"
                },
                "time": "89",
                "type": "Substitution",
                "assist": null,
                "player": "S. Walukiewicz",
                "playerId": 6533856,
                "substituted": "Pedro Felipe",
                "assistingPlayerId": 66825458
            }
        ],
        "predictions": {
            "live": [
                {
                    "type": "live",
                    "modelType": "three-way",
                    "description": "Sassuolo is most likely to win the game against Verona.",
                    "generatedAt": "2026-02-20T19:50:14.504Z",
                    "probabilities": {
                        "away": "19.85%",
                        "draw": "27.48%",
                        "home": "52.67%"
                    }
                },
                {
                    "type": "live",
                    "modelType": "three-way",
                    "description": "Sassuolo is most likely to win the game against Verona.",
                    "generatedAt": "2026-02-20T20:00:17.559Z",
                    "probabilities": {
                        "away": "20.76%",
                        "draw": "29.58%",
                        "home": "49.66%"
                    }
                },
                {
                    "type": "live",
                    "modelType": "three-way",
                    "description": "Sassuolo is most likely to win the game against Verona.",
                    "generatedAt": "2026-02-20T20:10:20.030Z",
                    "probabilities": {
                        "away": "23.89%",
                        "draw": "32.99%",
                        "home": "43.13%"
                    }
                },
                {
                    "type": "live",
                    "modelType": "three-way",
                    "description": "Sassuolo is most likely to win the game against Verona.",
                    "generatedAt": "2026-02-20T20:20:22.081Z",
                    "probabilities": {
                        "away": "22.63%",
                        "draw": "35.60%",
                        "home": "41.76%"
                    }
                },
                {
                    "type": "live",
                    "modelType": "three-way",
                    "description": "Sassuolo is most likely to win the game against Verona.",
                    "generatedAt": "2026-02-20T20:30:26.275Z",
                    "probabilities": {
                        "away": "3.70%",
                        "draw": "11.57%",
                        "home": "84.73%"
                    }
                },
                {
                    "type": "live",
                    "modelType": "three-way",
                    "description": "Sassuolo is most likely to win the game against Verona.",
                    "generatedAt": "2026-02-20T20:40:22.858Z",
                    "probabilities": {
                        "away": "2.50%",
                        "draw": "8.54%",
                        "home": "88.96%"
                    }
                },
                {
                    "type": "live",
                    "modelType": "three-way",
                    "description": "Sassuolo is most likely to win the game against Verona.",
                    "generatedAt": "2026-02-20T20:50:21.405Z",
                    "probabilities": {
                        "away": "2.33%",
                        "draw": "8.28%",
                        "home": "89.39%"
                    }
                },
                {
                    "type": "live",
                    "modelType": "three-way",
                    "description": "Sassuolo is most likely to win the game against Verona.",
                    "generatedAt": "2026-02-20T21:00:23.095Z",
                    "probabilities": {
                        "away": "1.78%",
                        "draw": "7.40%",
                        "home": "90.82%"
                    }
                },
                {
                    "type": "live",
                    "modelType": "three-way",
                    "description": "Sassuolo is most likely to win the game against Verona.",
                    "generatedAt": "2026-02-20T21:10:21.971Z",
                    "probabilities": {
                        "away": "1.41%",
                        "draw": "5.98%",
                        "home": "92.60%"
                    }
                },
                {
                    "type": "live",
                    "modelType": "three-way",
                    "description": "Sassuolo is most likely to win the game against Verona.",
                    "generatedAt": "2026-02-20T21:20:21.494Z",
                    "probabilities": {
                        "away": "1.41%",
                        "draw": "5.98%",
                        "home": "92.60%"
                    }
                },
                {
                    "type": "live",
                    "modelType": "three-way",
                    "description": "Sassuolo is most likely to win the game against Verona.",
                    "generatedAt": "2026-02-20T21:30:20.406Z",
                    "probabilities": {
                        "away": "1.44%",
                        "draw": "5.31%",
                        "home": "93.26%"
                    }
                }
            ],
            "prematch": [
                {
                    "type": "prematch",
                    "modelType": "three-way",
                    "description": "Sassuolo is most likely to win the game against Verona.",
                    "generatedAt": "2026-02-13T04:19:02.743Z",
                    "probabilities": {
                        "away": "21.09%",
                        "draw": "30.61%",
                        "home": "48.30%"
                    }
                },
                {
                    "type": "prematch",
                    "modelType": "three-way",
                    "description": "Sassuolo is most likely to win the game against Verona.",
                    "generatedAt": "2026-02-14T04:16:00.501Z",
                    "probabilities": {
                        "away": "21.42%",
                        "draw": "30.69%",
                        "home": "47.89%"
                    }
                },
                {
                    "type": "prematch",
                    "modelType": "three-way",
                    "description": "Sassuolo is most likely to win the game against Verona.",
                    "generatedAt": "2026-02-15T03:36:20.492Z",
                    "probabilities": {
                        "away": "22.20%",
                        "draw": "31.23%",
                        "home": "46.57%"
                    }
                },
                {
                    "type": "prematch",
                    "modelType": "three-way",
                    "description": "Sassuolo is most likely to win the game against Verona.",
                    "generatedAt": "2026-02-16T02:53:56.368Z",
                    "probabilities": {
                        "away": "20.13%",
                        "draw": "29.29%",
                        "home": "50.57%"
                    }
                },
                {
                    "type": "prematch",
                    "modelType": "three-way",
                    "description": "Sassuolo is most likely to win the game against Verona.",
                    "generatedAt": "2026-02-17T02:47:38.304Z",
                    "probabilities": {
                        "away": "19.68%",
                        "draw": "28.90%",
                        "home": "51.42%"
                    }
                },
                {
                    "type": "prematch",
                    "modelType": "three-way",
                    "description": "Sassuolo is most likely to win the game against Verona.",
                    "generatedAt": "2026-02-18T02:41:24.856Z",
                    "probabilities": {
                        "away": "19.05%",
                        "draw": "28.22%",
                        "home": "52.73%"
                    }
                },
                {
                    "type": "prematch",
                    "modelType": "three-way",
                    "description": "Sassuolo is most likely to win the game against Verona.",
                    "generatedAt": "2026-02-19T02:35:24.916Z",
                    "probabilities": {
                        "away": "19.10%",
                        "draw": "28.09%",
                        "home": "52.81%"
                    }
                },
                {
                    "type": "prematch",
                    "modelType": "three-way",
                    "description": "Sassuolo is most likely to win the game against Verona.",
                    "generatedAt": "2026-02-20T02:26:18.366Z",
                    "probabilities": {
                        "away": "18.09%",
                        "draw": "27.64%",
                        "home": "54.27%"
                    }
                },
                {
                    "type": "prematch",
                    "modelType": "three-way",
                    "description": "Sassuolo is most likely to win the game against Verona.",
                    "generatedAt": "2026-02-20T09:11:36.680Z",
                    "probabilities": {
                        "away": "18.45%",
                        "draw": "27.80%",
                        "home": "53.75%"
                    }
                }
            ]
        },
        "statistics": [
            {
                "team": {
                    "id": 416072,
                    "logo": "https://highlightly.net/soccer/images/teams/416072.png",
                    "name": "Sassuolo"
                },
                "statistics": [
                    {
                        "value": 2,
                        "displayName": "Expected Goals"
                    },
                    {
                        "value": 4,
                        "displayName": "Big Chances Created"
                    },
                    {
                        "value": 12,
                        "displayName": "Free Kicks"
                    },
                    {
                        "value": 0.59,
                        "displayName": "Expected Assists"
                    },
                    {
                        "value": 5,
                        "displayName": "Successful Crosses"
                    },
                    {
                        "value": 16,
                        "displayName": "Crosses"
                    },
                    {
                        "value": 228,
                        "displayName": "Passes Own Half"
                    },
                    {
                        "value": 214,
                        "displayName": "Passes Opposition Half"
                    },
                    {
                        "value": 16,
                        "displayName": "Successful Long Passes"
                    },
                    {
                        "value": 38,
                        "displayName": "Long Passes"
                    },
                    {
                        "value": 11,
                        "displayName": "Throw-Ins"
                    },
                    {
                        "value": 6,
                        "displayName": "Key Passes"
                    },
                    {
                        "value": 65,
                        "displayName": "Passes Into Final Third"
                    },
                    {
                        "value": 59,
                        "displayName": "Backward Passes"
                    },
                    {
                        "value": 9,
                        "displayName": "Goal Kicks"
                    },
                    {
                        "value": 9,
                        "displayName": "Interceptions"
                    },
                    {
                        "value": 8,
                        "displayName": "Successful Tackles"
                    },
                    {
                        "value": 13,
                        "displayName": "Tackles"
                    },
                    {
                        "value": 36,
                        "displayName": "Clearances"
                    },
                    {
                        "value": 9,
                        "displayName": "Successful Aerial Duels"
                    },
                    {
                        "value": 21,
                        "displayName": "Aerial Duels"
                    },
                    {
                        "value": 4,
                        "displayName": "Successful Dribbles"
                    },
                    {
                        "value": 7,
                        "displayName": "Dribbles"
                    },
                    {
                        "value": 0.5,
                        "displayName": "Shots accuracy"
                    },
                    {
                        "value": 58,
                        "displayName": "Failed passes"
                    },
                    {
                        "value": 0.47,
                        "displayName": "Possession"
                    },
                    {
                        "value": 4,
                        "displayName": "Shots on target"
                    },
                    {
                        "value": 6,
                        "displayName": "Corners"
                    },
                    {
                        "value": 1,
                        "displayName": "Offsides"
                    },
                    {
                        "value": 368,
                        "displayName": "Successful passes"
                    },
                    {
                        "value": 0,
                        "displayName": "Red cards"
                    },
                    {
                        "value": 3,
                        "displayName": "Shots off target"
                    },
                    {
                        "value": 1,
                        "displayName": "Blocked shots"
                    },
                    {
                        "value": 7,
                        "displayName": "Shots within penalty area"
                    },
                    {
                        "value": 426,
                        "displayName": "Total passes"
                    },
                    {
                        "value": 1,
                        "displayName": "Shots outside penalty area"
                    },
                    {
                        "value": 1,
                        "displayName": "Goalkeeper saves"
                    },
                    {
                        "value": 12,
                        "displayName": "Fouls"
                    },
                    {
                        "value": 2,
                        "displayName": "Yellow cards"
                    }
                ]
            },
            {
                "team": {
                    "id": 429688,
                    "logo": "https://highlightly.net/soccer/images/teams/429688.png",
                    "name": "Verona"
                },
                "statistics": [
                    {
                        "value": 0.71,
                        "displayName": "Expected Goals"
                    },
                    {
                        "value": 0,
                        "displayName": "Big Chances Created"
                    },
                    {
                        "value": 12,
                        "displayName": "Free Kicks"
                    },
                    {
                        "value": 0.46,
                        "displayName": "Expected Assists"
                    },
                    {
                        "value": 3,
                        "displayName": "Successful Crosses"
                    },
                    {
                        "value": 25,
                        "displayName": "Crosses"
                    },
                    {
                        "value": 251,
                        "displayName": "Passes Own Half"
                    },
                    {
                        "value": 247,
                        "displayName": "Passes Opposition Half"
                    },
                    {
                        "value": 28,
                        "displayName": "Successful Long Passes"
                    },
                    {
                        "value": 52,
                        "displayName": "Long Passes"
                    },
                    {
                        "value": 17,
                        "displayName": "Throw-Ins"
                    },
                    {
                        "value": 10,
                        "displayName": "Key Passes"
                    },
                    {
                        "value": 80,
                        "displayName": "Passes Into Final Third"
                    },
                    {
                        "value": 78,
                        "displayName": "Backward Passes"
                    },
                    {
                        "value": 6,
                        "displayName": "Goal Kicks"
                    },
                    {
                        "value": 13,
                        "displayName": "Interceptions"
                    },
                    {
                        "value": 9,
                        "displayName": "Successful Tackles"
                    },
                    {
                        "value": 12,
                        "displayName": "Tackles"
                    },
                    {
                        "value": 14,
                        "displayName": "Clearances"
                    },
                    {
                        "value": 12,
                        "displayName": "Successful Aerial Duels"
                    },
                    {
                        "value": 21,
                        "displayName": "Aerial Duels"
                    },
                    {
                        "value": 2,
                        "displayName": "Successful Dribbles"
                    },
                    {
                        "value": 7,
                        "displayName": "Dribbles"
                    },
                    {
                        "value": 0.08,
                        "displayName": "Shots accuracy"
                    },
                    {
                        "value": 66,
                        "displayName": "Failed passes"
                    },
                    {
                        "value": 0.53,
                        "displayName": "Possession"
                    },
                    {
                        "value": 1,
                        "displayName": "Shots on target"
                    },
                    {
                        "value": 5,
                        "displayName": "Corners"
                    },
                    {
                        "value": 2,
                        "displayName": "Offsides"
                    },
                    {
                        "value": 407,
                        "displayName": "Successful passes"
                    },
                    {
                        "value": 1,
                        "displayName": "Red cards"
                    },
                    {
                        "value": 5,
                        "displayName": "Shots off target"
                    },
                    {
                        "value": 6,
                        "displayName": "Blocked shots"
                    },
                    {
                        "value": 8,
                        "displayName": "Shots within penalty area"
                    },
                    {
                        "value": 473,
                        "displayName": "Total passes"
                    },
                    {
                        "value": 4,
                        "displayName": "Shots outside penalty area"
                    },
                    {
                        "value": 1,
                        "displayName": "Goalkeeper saves"
                    },
                    {
                        "value": 14,
                        "displayName": "Fouls"
                    },
                    {
                        "value": 2,
                        "displayName": "Yellow cards"
                    }
                ]
            }
        ],
        "id": 1172783457,
        "round": "Regular Season - 26",
        "date": "2026-02-20T19:45:00.000Z",
        "country": {
            "code": "IT",
            "name": "Italy",
            "logo": "https://highlightly.net/soccer/images/countries/IT.svg"
        },
        "state": {
            "clock": 90,
            "score": {
                "current": "3 - 0",
                "penalties": null
            },
            "description": "Finished"
        },
        "awayTeam": {
            "id": 429688,
            "logo": "https://highlightly.net/soccer/images/teams/429688.png",
            "name": "Verona",
            "topPlayers": [
                {
                    "name": "Amin Sarr",
                    "position": "Attacker",
                    "statistics": [
                        {
                            "name": "Expected Goals",
                            "value": 0.24
                        },
                        {
                            "name": "Shots On Target",
                            "value": 1
                        },
                        {
                            "name": "Minutes",
                            "value": 72
                        }
                    ]
                },
                {
                    "name": "Domagoj Bradarić",
                    "position": "Midfielder",
                    "statistics": [
                        {
                            "name": "Expected Goals",
                            "value": 0.03
                        },
                        {
                            "name": "Shots On Target",
                            "value": 0
                        },
                        {
                            "name": "Minutes",
                            "value": 72
                        }
                    ]
                },
                {
                    "name": "Victor Nelsson",
                    "position": "Defender",
                    "statistics": [
                        {
                            "name": "Interceptions",
                            "value": 2
                        },
                        {
                            "name": "Clearances",
                            "value": 5
                        },
                        {
                            "name": "Minutes",
                            "value": 90
                        }
                    ]
                }
            ],
            "shots": [
                {
                    "time": "15'",
                    "outcome": "Blocked",
                    "goalTarget": "Low Centre",
                    "playerName": "Kieron Bowie"
                },
                {
                    "time": "16'",
                    "outcome": "Blocked",
                    "goalTarget": "Low Right",
                    "playerName": "Amin Sarr"
                },
                {
                    "time": "21'",
                    "outcome": "Missed",
                    "goalTarget": null,
                    "playerName": "Martin Frese"
                },
                {
                    "time": "42'",
                    "outcome": "Blocked",
                    "goalTarget": "Low Centre",
                    "playerName": "Abdou Harroui"
                },
                {
                    "time": "48'",
                    "outcome": "Missed",
                    "goalTarget": null,
                    "playerName": "Moatasem Al-Musrati"
                },
                {
                    "time": "53'",
                    "outcome": "Missed",
                    "goalTarget": null,
                    "playerName": "Kieron Bowie"
                },
                {
                    "time": "60'",
                    "outcome": "Blocked",
                    "goalTarget": "Low Centre",
                    "playerName": "Amin Sarr"
                },
                {
                    "time": "60'",
                    "outcome": "Blocked",
                    "goalTarget": "Low Centre",
                    "playerName": "Moatasem Al-Musrati"
                },
                {
                    "time": "61'",
                    "outcome": "Blocked",
                    "goalTarget": "Low Left",
                    "playerName": "Amin Sarr"
                },
                {
                    "time": "69'",
                    "outcome": "Saved",
                    "goalTarget": "Low Left",
                    "playerName": "Amin Sarr"
                },
                {
                    "time": "72'",
                    "outcome": "Missed",
                    "goalTarget": null,
                    "playerName": "Domagoj Bradarić"
                },
                {
                    "time": "77'",
                    "outcome": "Missed",
                    "goalTarget": "CloseLeft",
                    "playerName": "Daniel Mosquera"
                }
            ]
        },
        "homeTeam": {
            "id": 416072,
            "logo": "https://highlightly.net/soccer/images/teams/416072.png",
            "name": "Sassuolo",
            "topPlayers": [
                {
                    "name": "Domenico Berardi",
                    "position": "Attacker",
                    "statistics": [
                        {
                            "name": "Expected Goals",
                            "value": 1.4
                        },
                        {
                            "name": "Shots On Target",
                            "value": 3
                        },
                        {
                            "name": "Minutes",
                            "value": 90
                        }
                    ]
                },
                {
                    "name": "Kristian Thorstvedt",
                    "position": "Midfielder",
                    "statistics": [
                        {
                            "name": "Expected Goals",
                            "value": 0.41
                        },
                        {
                            "name": "Shots On Target",
                            "value": 0
                        },
                        {
                            "name": "Minutes",
                            "value": 90
                        }
                    ]
                },
                {
                    "name": "Jay Idzes",
                    "position": "Defender",
                    "statistics": [
                        {
                            "name": "Interceptions",
                            "value": 1
                        },
                        {
                            "name": "Clearances",
                            "value": 11
                        },
                        {
                            "name": "Minutes",
                            "value": 90
                        }
                    ]
                }
            ],
            "shots": [
                {
                    "time": "37'",
                    "outcome": "Missed",
                    "goalTarget": null,
                    "playerName": "Luca Lipani"
                },
                {
                    "time": "40'",
                    "outcome": "Missed",
                    "goalTarget": null,
                    "playerName": "Fillipo Romagna"
                },
                {
                    "time": "40'",
                    "outcome": "Goal",
                    "goalTarget": "Low Centre",
                    "playerName": "Andrea Pinamonti"
                },
                {
                    "time": "44'",
                    "outcome": "Saved",
                    "goalTarget": "Low Centre",
                    "playerName": "Domenico Berardi"
                },
                {
                    "time": "44'",
                    "outcome": "Goal",
                    "goalTarget": "Low Centre",
                    "playerName": "Domenico Berardi"
                },
                {
                    "time": "54'",
                    "outcome": "Missed",
                    "goalTarget": null,
                    "playerName": "Kristian Thorstvedt"
                },
                {
                    "time": "62'",
                    "outcome": "Goal",
                    "goalTarget": "Low Left",
                    "playerName": "Domenico Berardi"
                },
                {
                    "time": "67'",
                    "outcome": "Blocked",
                    "goalTarget": "Low Right",
                    "playerName": "Kristian Thorstvedt"
                }
            ]
        },
        "league": {
            "id": 115669,
            "logo": "https://highlightly.net/soccer/images/leagues/115669.png",
            "name": "Serie A",
            "season": 2025
        },
        "news": []
    }
]
        

There's no top-level status. Instead, everything lives inside a state object. The state.description tells you what phase the match is in (things like Not Started, First Half, Half Time, Second Half, Extra Time, Penalty Shootout, Finished, Postponed, Suspended, etc.). The score is a formatted string in state.score.current like 3 - 0, and state.clock gives you the current match minute.

For the UI, we'll collapse those descriptions into four groups: scheduled (not started yet), live (any active play or half-time break), ended (finished, however it ended), and other (postponed, suspended, canceled). That keeps the badge logic clean.

The match detail response also includes an events array. Each event looks like this:

          {
  "team": { "id": 512, "name": "Mexico", "logo": "https://..." },
  "time": "40",
  "type": "Goal",
  "player": "S. Gimenez",
  "playerId": 5006288,
  "assist": "E. Alvarez",
  "assistingPlayerId": 356769,
  "substituted": null
}
        

You'll also find venue, referee, forecast (weather), statistics (per-team match stats like possession, xG, shots, corners), predictions (pre-match and live win probabilities), and shots data on each team. We'll build a statistics panel later in the tutorial. The rest is there if you want to extend the tracker further.

To keep things feeling real-time, we'll poll every 60 seconds. That's a good balance between freshness and not blowing through your rate limit.

Building the Live Score UI

The match cards are the core of the app. Each card shows two teams, the score (or kickoff time), and a status indicator. Create a new src/components/MatchCard.jsx file:

          import React from "react";

// Map API state descriptions to our four UI categories
const LIVE_STATES = [
  "First Half", "Second Half", "Half Time",
  "Extra Time", "Extra Time Half Time",
  "Penalty Shootout", "Break Time",
];
const ENDED_STATES = [
  "Finished", "Finished AET", "Finished AP",
  "Finished After Extra Time", "Finished After Penalties",
];
const SCHEDULED_STATES = ["Not Started"];

function getMatchPhase(description) {
  if (!description) return "scheduled";
  if (LIVE_STATES.includes(description)) return "live";
  if (ENDED_STATES.includes(description)) return "ended";
  if (SCHEDULED_STATES.includes(description)) return "scheduled";
  // Anything else — Postponed, Suspended, Cancelled, Abandoned, etc.
  return "other";
}

function StatusBadge({ description, clock }) {
  const phase = getMatchPhase(description);

  const config = {
    live:      { bg: "#e74c3c", label: clock ? `${clock}'` : "LIVE" },
    ended:     { bg: "#95a5a6", label: "FT" },
    scheduled: { bg: "#3498db", label: "Upcoming" },
    other:     { bg: "#e67e22", label: description || "TBD" },
  };

  const { bg, label } = config[phase];

  return (
    <span
      style={{
        backgroundColor: bg,
        color: "white",
        padding: "4px 12px",
        borderRadius: "12px",
        fontSize: "12px",
        fontWeight: "bold",
        textTransform: "uppercase",
        animation: phase === "live" ? "pulse 1.5s infinite" : "none",
      }}
    >
      {label}
    </span>
  );
}

function MatchCard({ match, onClick }) {
  const { state } = match;
  const phase = getMatchPhase(state?.description);

  return (
    <div
      onClick={() => onClick(match.id)}
      style={{
        display: "flex",
        alignItems: "center",
        justifyContent: "space-between",
        padding: "16px 24px",
        margin: "8px 0",
        borderRadius: "12px",
        backgroundColor: "#ffffff",
        boxShadow: "0 2px 8px rgba(0,0,0,0.08)",
        cursor: "pointer",
        transition: "transform 0.15s ease",
      }}
    >
      {/* Home Team */}
      <div style={{ display: "flex", alignItems: "center", flex: 1 }}>
        <img
          src={match.homeTeam.logo}
          alt=""
          style={{ width: 36, height: 36, marginRight: 12 }}
        />
        <span style={{ fontWeight: 600, fontSize: 16 }}>
          {match.homeTeam.name}
        </span>
      </div>

      {/* Score or Kickoff Time */}
      <div style={{ textAlign: "center", minWidth: 120 }}>
        {phase === "scheduled" ? (
          <div style={{ fontSize: 14, color: "#666" }}>
            {new Date(match.date).toLocaleTimeString([], {
              hour: "2-digit",
              minute: "2-digit",
            })}
          </div>
        ) : state?.score?.current ? (
          <div style={{ fontSize: 28, fontWeight: 700, letterSpacing: 2 }}>
            {state.score.current}
          </div>
        ) : null}
        {state?.score?.penalties && (
          <div style={{ fontSize: 12, color: "#888" }}>
            (Pens: {state.score.penalties})
          </div>
        )}
        <StatusBadge description={state?.description} clock={phase === "live" ? state?.clock : null} />
      </div>

      {/* Away Team */}
      <div
        style={{
          display: "flex",
          alignItems: "center",
          justifyContent: "flex-end",
          flex: 1,
        }}
      >
        <span style={{ fontWeight: 600, fontSize: 16 }}>
          {match.awayTeam.name}
        </span>
        <img
          src={match.awayTeam.logo}
          alt=""
          style={{ width: 36, height: 36, marginLeft: 12 }}
        />
      </div>
    </div>
  );
}

export { getMatchPhase };
export default MatchCard; 
        

There's a bit more going on here than a simple score card, so let's break it down.

The API doesn't give you a simple live or finished status. Instead, state.description returns the actual phase of the match.

We collapse all those descriptions into four UI-friendly groups using the getMatchPhase() helper: scheduled (match hasn't kicked off), live (any active play or break such as first half, half time, second half, extra time, penalties), ended (all flavors of finished), and other (postponed, suspended, canceled or abandoned).

The score comes from state.score.current as a pre-formatted string, so we just render it directly. If there were penalties, state.score.penalties will have that too, and we show it in parentheses below the main score.

For live matches, the badge shows the current match minute from state.clock (e.g. 72') instead of a generic label.

You'll also want to add the pulse animation to your CSS. Drop this in src/index.css:

          @keyframes pulse {
  0%, 100% { opacity: 1; }
  50% { opacity: 0.6; }
}
        

Displaying Match Events

When someone clicks a match card, they want the details such as who scored, who got booked, which substitutions happened. Let's create the src/components/EventTimeline.jsx file:

          import React from "react";

// Keys match the exact `type` strings from the Highlightly API
const EVENT_CONFIG = {
  "Goal":            { icon: "\u26BD", accent: "#27ae60", highlight: true },
  "Yellow Card":     { icon: "\uD83D\uDFE8", accent: "#f0c040", highlight: false },
  "Red Card":        { icon: "\uD83D\uDFE5", accent: "#e74c3c", highlight: false },
  "Substitution":    { icon: "\uD83D\uDD04", accent: "#ecf0f1", highlight: false },
  "Missed Penalty":  { icon: "\u26BD\u274C", accent: "#e74c3c", highlight: false },
};

const DEFAULT_CONFIG = { icon: "\u25CB", accent: "#ecf0f1", highlight: false };

function EventRow({ event }) {
  const config = EVENT_CONFIG[event.type] || DEFAULT_CONFIG;

  return (
    <div
      style={{
        display: "flex",
        alignItems: "center",
        padding: "10px 16px",
        marginBottom: 4,
        borderLeft: `3px solid ${config.accent}`,
        backgroundColor: config.highlight ? "#f0faf4" : "transparent",
        borderRadius: "0 8px 8px 0",
      }}
    >
      <span style={{ fontSize: 14, fontWeight: 700, minWidth: 40, color: "#555" }}>
        {event.time}'
      </span>
      <span style={{ fontSize: 20, marginRight: 12 }}>{config.icon}</span>
      <div>
        <img
          src={event.team?.logo}
          alt=""
          style={{ width: 18, height: 18, marginRight: 6, verticalAlign: "middle" }}
        />
        <span style={{ fontWeight: 600 }}>{event.player}</span>
        {event.type === "Goal" && event.assist && (
          <span style={{ color: "#888", fontSize: 13 }}>
            {" "}(assist: {event.assist})
          </span>
        )}
        {event.type === "Substitution" && event.substituted && (
          <span style={{ color: "#888", fontSize: 13 }}>
            {" "}replaced by {event.substituted}
          </span>
        )}
      </div>
    </div>
  );
}

function EventTimeline({ events }) {
  if (!events || events.length === 0) {
    return (
      <p style={{ color: "#999", textAlign: "center", padding: "20px 0" }}>
        No match events yet. Check back once the game kicks off.
      </p>
    );
  }

  // Sort by time so the timeline reads chronologically
  const sorted = [...events].sort((a, b) => parseInt(a.time) - parseInt(b.time));

  return (
    <div style={{ padding: "16px 0" }}>
      <h3 style={{ marginBottom: 16, fontSize: 18 }}>Match Events</h3>
      {sorted.map((event, i) => (
        <EventRow key={`${event.time}-${event.type}-${event.playerId}-${i}`} event={event} />
      ))}
    </div>
  );
}

export default EventTimeline;
        

Here's how the event fields map to the UI. time is the match minute the event occurred, type is a readable label such as Goal, Yellow Card, Red Card, Substitution, or Missed Penalty, player is the main actor (the scorer, the booked player, or the player going off in a sub), assist is the player who set up a goal, substituted is the player coming on to replace player. Each event also carries a team object with name and logo, so you can badge every row without cross-referencing the match data.

Goals get a green left border and a highlighted background so they pop. Cards get a yellow or red accent. Subs are neutral. The whole thing reads chronologically, top to bottom.

We sort by parseInt(event.time) since the API returns the minute as a string.

Showing Match Statistics

Once a match kicks off, the API starts populating a statistics array on the match object. Each entry corresponds to one team and contains an array of { value, displayName } pairs. The stats include entries such as possession, expected goals, shots on target, corners, fouls, total passes, and a bunch more. It's essentially the same stat panel you'd see on a TV broadcast.

Let's create a new src/components/MatchStats.jsx file:

          import React from "react";

// Stats we always want at the top, in this order
const PRIORITY_STATS = [
  "Possession",
  "Expected Goals",
  "Total shots",
  "Shots on target",
  "Shots off target",
  "Corner kicks",
  "Fouls",
  "Offsides",
  "Yellow cards",
  "Red cards",
];

function formatValue(value, displayName) {
  if (displayName === "Possession") {
    // API gives possession as a decimal like 0.62
    return typeof value === "number" && value <= 1
      ? `${Math.round(value * 100)}%`
      : `${value}%`;
  }
  if (displayName === "Expected Goals") {
    return typeof value === "number" ? value.toFixed(2) : value;
  }
  return value ?? "-";
}

function getBarRatio(homeVal, awayVal, displayName) {
  // For possession, values are already proportional
  if (displayName === "Possession") {
    const h = typeof homeVal === "number" && homeVal <= 1 ? homeVal : parseFloat(homeVal) / 100;
    return Math.max(0.05, Math.min(0.95, h));
  }
  const h = parseFloat(homeVal) || 0;
  const a = parseFloat(awayVal) || 0;
  if (h + a === 0) return 0.5;
  return Math.max(0.05, Math.min(0.95, h / (h + a)));
}

function StatRow({ displayName, homeValue, awayValue }) {
  const ratio = getBarRatio(homeValue, awayValue, displayName);
  const homeFormatted = formatValue(homeValue, displayName);
  const awayFormatted = formatValue(awayValue, displayName);

  // Highlight the "winning" side
  const h = parseFloat(homeValue) || 0;
  const a = parseFloat(awayValue) || 0;
  const homeBold = h > a;
  const awayBold = a > h;

  return (
    <div style={{ marginBottom: 12 }}>
      {/* Stat label */}
      <div
        style={{
          textAlign: "center",
          fontSize: 12,
          color: "#888",
          marginBottom: 4,
          textTransform: "uppercase",
          letterSpacing: 0.5,
        }}
      >
        {displayName}
      </div>
      {/* Values and bar */}
      <div style={{ display: "flex", alignItems: "center", gap: 10 }}>
        <span
          style={{
            minWidth: 42,
            textAlign: "right",
            fontWeight: homeBold ? 700 : 400,
            fontSize: 14,
            color: homeBold ? "#2c3e50" : "#888",
          }}
        >
          {homeFormatted}
        </span>
        <div
          style={{
            flex: 1,
            height: 8,
            backgroundColor: "#f0f0f0",
            borderRadius: 4,
            overflow: "hidden",
            display: "flex",
          }}
        >
          <div
            style={{
              width: `${ratio * 100}%`,
              backgroundColor: homeBold ? "#3498db" : "#a0c4e8",
              borderRadius: "4px 0 0 4px",
              transition: "width 0.4s ease",
            }}
          />
          <div
            style={{
              flex: 1,
              backgroundColor: awayBold ? "#e74c3c" : "#f0a8a0",
              borderRadius: "0 4px 4px 0",
              transition: "width 0.4s ease",
            }}
          />
        </div>
        <span
          style={{
            minWidth: 42,
            textAlign: "left",
            fontWeight: awayBold ? 700 : 400,
            fontSize: 14,
            color: awayBold ? "#2c3e50" : "#888",
          }}
        >
          {awayFormatted}
        </span>
      </div>
    </div>
  );
}

function MatchStats({ statistics }) {
  if (!statistics || statistics.length < 2) {
    return (
      <p style={{ color: "#999", textAlign: "center", padding: "20px 0" }}>
        No match statistics available yet.
      </p>
    );
  }

  const home = statistics[0];
  const away = statistics[1];

  // Build a lookup from displayName → value for each team
  const homeMap = {};
  (home.statistics || []).forEach((s) => { homeMap[s.displayName] = s.value; });
  const awayMap = {};
  (away.statistics || []).forEach((s) => { awayMap[s.displayName] = s.value; });

  // Collect all stat names, prioritized ones first
  const allNames = new Set([
    ...Object.keys(homeMap),
    ...Object.keys(awayMap),
  ]);
  const prioritized = PRIORITY_STATS.filter((n) => allNames.has(n));
  const remaining = [...allNames].filter((n) => !PRIORITY_STATS.includes(n));
  const orderedStats = [...prioritized, ...remaining];

  return (
    <div style={{ padding: "16px 0" }}>
      <h3 style={{ marginBottom: 16, fontSize: 18 }}>Match Statistics</h3>

      {/* Team headers */}
      <div
        style={{
          display: "flex",
          justifyContent: "space-between",
          alignItems: "center",
          marginBottom: 20,
          padding: "0 4px",
        }}
      >
        <div style={{ display: "flex", alignItems: "center", gap: 8 }}>
          <img src={home.team.logo} alt="" style={{ width: 28, height: 28 }} />
          <span style={{ fontWeight: 600, fontSize: 14 }}>{home.team.name}</span>
        </div>
        <div style={{ display: "flex", alignItems: "center", gap: 8 }}>
          <span style={{ fontWeight: 600, fontSize: 14 }}>{away.team.name}</span>
          <img src={away.team.logo} alt="" style={{ width: 28, height: 28 }} />
        </div>
      </div>

      {/* Stat rows */}
      {orderedStats.map((name) => (
        <StatRow
          key={name}
          displayName={name}
          homeValue={homeMap[name]}
          awayValue={awayMap[name]}
        />
      ))}
    </div>
  );
}

export default MatchStats;
        

Let's walk through the interesting bits.

The PRIORITY_STATS array controls which stats appear at the top. The ones people actually look for first (possession, xG, shots, corners). Everything else the API returns still gets rendered, just below the headline stats. That way the component handles whatever the API sends without breaking, and if Highlightly adds new stat types in the future, they'll show up automatically.

Formatting needs a little attention because not all stats are integers. Possession arrives as a decimal like 0.62, which we convert to 62%. Expected Goals gets two decimal places (2.14). Everything else is shown as-is.

The bar chart for each stat is a pair of div's inside a flex container. The home team's bar (blue) takes up a proportion of the width based on its share of the combined total, so if Mexico has 7 shots on target and Canada has 2, the blue bar fills about 78% of the row. The away team's bar (red) fills the rest. There's a 5%-95% clamp, so neither side ever completely disappears, even on a 10-0 stat.

The winning side for each stat gets bold text and a saturated bar color, while the lower side gets a lighter shade and normal weight. Small thing, but it makes it much easier to scan the panel quickly.

For live matches, the stats update every time you re-fetch the match details (which happens on our 60-second polling interval). The bar widths have a CSS transition on them, so when possession shifts from 55% to 48%, the bars slide smoothly instead of jumping. Looks really nice during a live game.

Adding Team Lineups

Lineups are fetched from their own dedicated endpoint. They typically become available about 30 minutes before kickoff (or at the latest, 15 minutes after the game starts). When they're there, they add a lot to the experience.

The response structure has a couple of things worth knowing about. The first row of initialLineup is always the goalkeeper, and the remaining rows map directly to the formation, so if the formation is 4-3-3, you'll get rows with 1 GK, 4 defenders, 3 midfielders, and 3 forwards.

Let's create src/components/Lineups.jsx file:

          import React from "react";

function PlayerRow({ player, muted = false }) {
  return (
    <div
      style={{
        display: "flex",
        justifyContent: "space-between",
        padding: "6px 0",
        borderBottom: muted ? "none" : "1px solid #f0f0f0",
        fontSize: muted ? 13 : 14,
        color: muted ? "#888" : "#333",
      }}
    >
      <span>
        <strong style={{ color: muted ? "#bbb" : "#999", marginRight: 8, display: "inline-block", minWidth: 24 }}>
          {player.shirtNumber || player.number}
        </strong>
        {player.name}
      </span>
      {!muted && player.position && (
        <span style={{ color: "#aaa", fontSize: 12 }}>{player.position}</span>
      )}
    </div>
  );
}

function TeamLineup({ team, lineup }) {
  // The API returns initialLineup (starting XI) and substitutes
  const starters = lineup.initialLineup || [];
  const subs = lineup.substitutes || [];

  return (
    <div style={{ flex: 1, padding: "0 12px" }}>
      <div style={{ textAlign: "center", marginBottom: 16 }}>
        <img src={team.logo} alt="" style={{ width: 40, height: 40 }} />
        <h4 style={{ margin: "8px 0 4px" }}>{team.name}</h4>
        {lineup.formation && (
          <span
            style={{
              fontSize: 13,
              color: "#666",
              backgroundColor: "#f0f0f0",
              padding: "2px 10px",
              borderRadius: 8,
            }}
          >
            {lineup.formation}
          </span>
        )}
        {lineup.coach?.name && (
          <div style={{ fontSize: 12, color: "#999", marginTop: 4 }}>
            Coach: {lineup.coach.name}
          </div>
        )}
      </div>

      <h5 style={{ color: "#333", marginBottom: 8, fontSize: 13, textTransform: "uppercase", letterSpacing: 0.5 }}>
        Starting XI
      </h5>
      {starters.flat().map((player, i) => (
        <PlayerRow key={player.playerId || i} player={player} />
      ))}

      {subs.length > 0 && (
        <div style={{ marginTop: 16 }}>
          <h5 style={{ color: "#999", marginBottom: 8, fontSize: 13, textTransform: "uppercase", letterSpacing: 0.5 }}>
            Substitutes
          </h5>
          {subs.map((player, i) => (
            <PlayerRow key={player.playerId || i} player={player} muted />
          ))}
        </div>
      )}
    </div>
  );
}

function Lineups({ homeTeam, awayTeam, homeLineup, awayLineup }) {
  if (!homeLineup && !awayLineup) {
    return (
      <p style={{ color: "#999", textAlign: "center", padding: "20px 0" }}>
        Lineups aren't confirmed yet — they usually come out about 30 minutes before kickoff.
      </p>
    );
  }

  return (
    <div>
      <h3 style={{ marginBottom: 16, fontSize: 18 }}>Lineups</h3>
      <div style={{ display: "flex", gap: 24 }}>
        {homeLineup && <TeamLineup team={homeTeam} lineup={homeLineup} />}
        {awayLineup && <TeamLineup team={awayTeam} lineup={awayLineup} />}
      </div>
    </div>
  );
}

export default Lineups;
        

Home on the left, away on the right. Formation displayed as a little pill under the team name, with the coach's name below it.

Notice the .flat() call on starters. That's because initialLineup comes back as a nested array grouped by formation rows (GK row, DEF row, MID row, FWD row). We flatten it into a single list for the simple view. If you wanted to get fancy later, you could keep the grouping and render players in a pitch diagram instead.

Embedding Video Highlights

This is the part that really makes the tracker worth building. Most sports APIs give you numbers such as scores, stats, that kind of thing. Highlightly also gives you videos, goal clips, full match recaps, post-match interviews and penalty shootouts.

Create a new src/components/Highlights.jsx file:

          import React, { useState } from "react";

const DEFAULT_THUMBNAIL =
  "https://upload.wikimedia.org/wikipedia/commons/1/14/No_Image_Available.jpg?20200913095930";

function HighlightCard({ highlight }) {
  const [hovered, setHovered] = useState(false);
  const linkUrl = highlight.url || highlight.embedUrl;
  const thumbnailUrl = highlight.imgUrl || DEFAULT_THUMBNAIL;

  return (
    <a
      href={linkUrl}
      target="_blank"
      rel="noopener noreferrer"
      style={{ textDecoration: "none", color: "inherit", display: "block" }}
      onMouseEnter={() => setHovered(true)}
      onMouseLeave={() => setHovered(false)}
    >
      <div
        style={{
          borderRadius: 12,
          overflow: "hidden",
          backgroundColor: "#fff",
          boxShadow: hovered
            ? "0 8px 24px rgba(0,0,0,0.15)"
            : "0 2px 8px rgba(0,0,0,0.08)",
          transition: "box-shadow 0.25s ease, transform 0.25s ease",
          transform: hovered ? "translateY(-3px)" : "translateY(0)",
        }}
      >
        {/* Thumbnail */}
        <div
          style={{
            position: "relative",
            backgroundColor: "#000",
            overflow: "hidden",
            height: 180,
          }}
        >
          <img
            src={thumbnailUrl}
            alt={highlight.title}
            onError={(e) => { e.target.src = DEFAULT_THUMBNAIL; }}
            style={{
              width: "100%",
              height: "100%",
              objectFit: "cover",
              display: "block",
              transition: "transform 0.3s ease, opacity 0.3s ease",
              transform: hovered ? "scale(1.05)" : "scale(1)",
              opacity: hovered ? 1 : 0.85,
            }}
          />
          {/* Play button overlay */}
          <div
            style={{
              position: "absolute",
              top: "50%",
              left: "50%",
              transform: "translate(-50%, -50%)",
              width: 56,
              height: 56,
              borderRadius: "50%",
              backgroundColor: hovered ? "rgba(231,76,60,0.85)" : "rgba(0,0,0,0.65)",
              display: "flex",
              alignItems: "center",
              justifyContent: "center",
              transition: "background-color 0.25s ease",
            }}
          >
            <div
              style={{
                width: 0,
                height: 0,
                borderTop: "11px solid transparent",
                borderBottom: "11px solid transparent",
                borderLeft: "18px solid white",
                marginLeft: 3,
              }}
            />
          </div>
        </div>

        {/* Label and title */}
        <div style={{ padding: "12px 16px" }}>
          <span
            style={{
              fontSize: 11,
              fontWeight: 700,
              textTransform: "uppercase",
              color: "#e74c3c",
              letterSpacing: 0.5,
            }}
          >
            {highlight.type?.replace(/_/g, " ")}
          </span>
          <h4 style={{ margin: "4px 0 0", fontSize: 15, lineHeight: 1.4 }}>
            {highlight.title}
          </h4>
          {highlight.source && (
            <span style={{ fontSize: 12, color: "#999" }}>{highlight.source}</span>
          )}
        </div>
      </div>
    </a>
  );
}

function HighlightsGrid({ highlights }) {
  if (!highlights || highlights.length === 0) {
    return (
      <p style={{ color: "#999", textAlign: "center", padding: "20px 0" }}>
        No highlights yet. Clips usually show up shortly after key moments in the match.
      </p>
    );
  }

  return (
    <div>
      <h3 style={{ marginBottom: 16, fontSize: 18 }}>Highlights</h3>
      <div
        style={{
          display: "grid",
          gridTemplateColumns: "repeat(auto-fill, minmax(280px, 1fr))",
          gap: 16,
        }}
      >
        {highlights.map((hl) => (
          <HighlightCard key={hl.id} highlight={hl} />
        ))}
      </div>
    </div>
  );
}

export default HighlightsGrid;
        

This version is simpler and works better in practice. Instead of embedding an iframe inside the card, each highlight is just an <a> tag that opens the video in a new tab. It uses highlight.url (the direct link to the video) with highlight.embedUrl as a fallback.

The hover effect is all done through a hovered state toggle. When you mouse over a card, three things happen at once: the thumbnail scales up slightly and goes to full opacity, the card lifts with a subtle and a deeper shadow, and the play button overlay shifts from dark to Highlightly's red. All transitions are 250-300ms so it feels smooth without being sluggish.

The highlight type label gets cleaned up with replace(/_/g, " ") for any snake case values. And during a live match, you'd want to show goal clips at the top.

Putting It All Together

Now let's assemble everything into the main App component. This is where the match list connects to the detail view:

          import React, { useState, useEffect, useCallback } from "react";
import MatchCard from "./components/MatchCard";
import EventTimeline from "./components/EventTimeline";
import MatchStats from "./components/MatchStats";
import Lineups from "./components/Lineups";
import HighlightsGrid from "./components/Highlights";
import { getMatches, getMatchDetails, getLineups, getHighlights } from "./api";

function App() {
  const [matches, setMatches] = useState([]);
  const [selectedMatch, setSelectedMatch] = useState(null);
  const [lineups, setLineups] = useState(null);
  const [highlights, setHighlights] = useState([]);
  const [currentDate, setCurrentDate] = useState(
    new Date().toISOString().split("T")[0]
  );
  const [loading, setLoading] = useState(true);
  const [error, setError] = useState(null);

  const fetchMatches = useCallback(async () => {
    setLoading(true);
    setError(null);
    try {
      const data = await getMatches(currentDate);
      setMatches(data.data || []);
    } catch (err) {
      console.error("Failed to load matches:", err);
      setError("Couldn't load matches. Check your connection and try again.");
    }
    setLoading(false);
  }, [currentDate]);

  useEffect(() => {
    fetchMatches();

    // Poll every 60 seconds for live updates
    const interval = setInterval(fetchMatches, 60000);
    return () => clearInterval(interval);
  }, [fetchMatches]);

  async function handleMatchClick(matchId) {
    try {
      // Fetch match details, lineups, and highlights in parallel
      const [details, lineupsData, hlData] = await Promise.all([
        getMatchDetails(matchId),
        getLineups(matchId),
        getHighlights({ matchId }),
      ]);
      setSelectedMatch(details.data || details[0]);
      setLineups(lineupsData.data || lineupsData);
      setHighlights(hlData.data || []);
    } catch (err) {
      console.error("Failed to load match details:", err);
    }
  }

  function changeDate(offset) {
    const d = new Date(currentDate);
    d.setDate(d.getDate() + offset);
    setCurrentDate(d.toISOString().split("T")[0]);
    setSelectedMatch(null);
    setLineups(null);
  }

  const formattedDate = new Date(currentDate + "T12:00:00").toLocaleDateString(
    "en-US",
    { weekday: "long", month: "long", day: "numeric" }
  );

  return (
    <div
      style={{
        maxWidth: 960,
        margin: "0 auto",
        padding: "24px 16px",
        fontFamily: '-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif',
        backgroundColor: "#f8f9fa",
        minHeight: "100vh",
      }}
    >
      {/* Header */}
      <header style={{ textAlign: "center", marginBottom: 32 }}>
        <h1 style={{ fontSize: 28, fontWeight: 800, margin: 0 }}>
          World Cup 2026 Live Tracker
        </h1>
        <p style={{ color: "#666", marginTop: 4 }}>
          Powered by{" "}
          <a href="https://highlightly.net" style={{ color: "#e74c3c", textDecoration: "none" }}>
            Highlightly
          </a>
        </p>
      </header>

      {/* Date Navigation */}
      <div
        style={{
          display: "flex",
          justifyContent: "center",
          alignItems: "center",
          gap: 16,
          marginBottom: 24,
        }}
      >
        <button onClick={() => changeDate(-1)} style={navBtnStyle}>
          &larr; Previous Day
        </button>
        <span style={{ fontWeight: 700, fontSize: 16 }}>{formattedDate}</span>
        <button onClick={() => changeDate(1)} style={navBtnStyle}>
          Next Day &rarr;
        </button>
      </div>

      {/* Match List */}
      {loading && <p style={{ textAlign: "center", color: "#999" }}>Loading matches...</p>}
      {error && <p style={{ textAlign: "center", color: "#e74c3c" }}>{error}</p>}
      {!loading && !error && matches.length === 0 && (
        <p style={{ textAlign: "center", color: "#999" }}>
          No World Cup matches on this date.
        </p>
      )}
      {!loading &&
        matches.map((match) => (
          <MatchCard key={match.id} match={match} onClick={handleMatchClick} />
        ))}

      {/* Match Detail Panel */}
      {selectedMatch && (
        <div
          style={{
            marginTop: 32,
            padding: 24,
            backgroundColor: "#fff",
            borderRadius: 16,
            boxShadow: "0 4px 16px rgba(0,0,0,0.06)",
          }}
        >
          <button
            onClick={() => setSelectedMatch(null)}
            style={{
              background: "none",
              border: "none",
              color: "#e74c3c",
              cursor: "pointer",
              fontSize: 14,
              marginBottom: 16,
              padding: 0,
            }}
          >
            &larr; Back to all matches
          </button>

          <EventTimeline events={selectedMatch.events} />

          <hr style={{ border: "none", borderTop: "1px solid #f0f0f0", margin: "24px 0" }} />

          <MatchStats statistics={selectedMatch.statistics} />

          <hr style={{ border: "none", borderTop: "1px solid #f0f0f0", margin: "24px 0" }} />

          <Lineups
            homeTeam={selectedMatch.homeTeam}
            awayTeam={selectedMatch.awayTeam}
            homeLineup={lineups?.homeTeam}
            awayLineup={lineups?.awayTeam}
          />

          <hr style={{ border: "none", borderTop: "1px solid #f0f0f0", margin: "24px 0" }} />

          <HighlightsGrid highlights={highlights} />
        </div>
      )}
    </div>
  );
}

const navBtnStyle = {
  background: "none",
  border: "1px solid #ddd",
  borderRadius: 8,
  padding: "8px 16px",
  cursor: "pointer",
  fontSize: 14,
  color: "#333",
};

export default App;
        

The flow is what you'd expect. You land on the page, see today's matches, click one, and the detail panel slides in with events, statistics, lineups, and video highlights. The date navigator lets you step forward and back through the schedule.

A few things worth pointing out. The handleMatchClick function fires off three API calls in parallel for match details, lineups, and highlights. Since lineups live at their own endpoint, we fetch them separately and store them in their own state.

We've also got an error state so the app doesn't silently fail if the API is down, proper key props on the match list, and the date formatting is pulled out of the JSX to keep the render cleaner.

Going Live

Once everything works locally, shipping the built project is pretty quick.

Frontend on Vercel

Assuming you have already worked with Vercel, run the following commands:

          npm run build
npx vercel --prod
        
Set REACT_APP_API_URL in the Vercel dashboard to point at your deployed backend.

If you have no prior experience with Vercel, we suggest taking a look at their documentation.

Backend on Render

Push your server/ folder to a GitHub repo, connect it to Render, set the environment variables and you're done. Their free tier is fine for a side project.

Before you launch, a few things worth doing
  • Implement a caching strategy. Since multiple users will be making the same requests, you should store the data for a brief period of time before attempting to retrieve newer fresh data. This will prevent API daily quota starvation.
  • Cache finished matches. Once a game is in a finished state, the data is highly unlikely to change. Cache it in-memory or save it inside a database.
  • Double check your .gitignore file. Make sure that the .env is in there. Leaking API keys to GitHub is a rite of passage nobody needs.
  • Make the UI responsive. During a World Cup game, a lot of your traffic will be mobile. Some fine-tuning will be needed.
  • Update loading animations with skeletons.

Ideas for What to Build Next

At this point you've got a solid tracker with live scores, events, statistics, lineups, and video highlights. But there's still plenty you can add:

  • Live win probability: the predictions.live array gives you updated three-way probabilities (home/draw/away) throughout the match. Show it as a live-updating chart that shifts as goals go in. The predictions.prematch data lets you show the before picture too.
  • Shot map: each team's shots array includes timing, outcome, goal target zone, and player name. You could plot these on a pitch diagram since fans love seeing where the chances came from.
  • Venue and weather info: The venue and forecast fields give you stadium name, city, capacity, and current weather. A small card above the match events showing Estadio Azteca, Mexico City - 28°C, clear sky adds atmosphere.
  • Goal notifications: Wire up a service worker to send push notifications when there's a goal in a match the user is following. This is the feature that turns casual visitors into daily users.
  • Standings endpoint: Use additional endpoints for standing retrieval and render them within the match details section.
  • Player box scores endpoint: Use additional endpoints for player box retrieval and render them within the match details section.

And that's just scratching the surface. The Highlightly Football API covers a lot more than what we've used here such as standings, head-to-head records, top scorers, player stats, transfers, pre-match odds, and more across 850+ leagues. Check out the full API documentation to see everything that's available. The World Cup tracker is a great starting point, but the same endpoints and patterns work for any league or competition in the platform.

Wrapping Up

The 2026 World Cup kicks off June 11th. That gives you a few months to build this, polish it, and get it in front of people before the opening match. About 104 games across 31 days is more than enough to build and sustain an audience.

The nice thing about using Highlightly is that you're not juggling three different APIs for scores, stats, and video. It's all in one place, scoped to one league ID. Less plumbing, more building.

Head to Highlightly to grab an API key, filter by league ID 1635, and start building. If you ship something cool, share it with us. We'd love to see it.