DEV Community

Highlightly for Highlightly

Posted on

Build a World Cup 2026 Live Score App in 20 Minutes

The World Cup kicks off on June 11 with 48 teams, 104 matches, 16 cities across the US, Canada, and Mexico.

We thought it would be fun to build a live score tracker for it. Not a React app with 47 dependencies. Just a clean little project with vanilla JS and a free API that we can open in a browser and watch scores roll in.

By the end of this post we'll have a working app that pulls live scores, shows match events on a timeline, renders key stats with visual bars, and embeds video highlights with thumbnails. All from a single API. Let's go.

What we're building

Here's what the finished app does.

  • Shows all football matches for a given day with team logos and kickoff times
  • Displays live scores that update every 60 seconds without collapsing open panels
  • Lets you expand a match to see a timeline of goals, cards, and substitutions split by team
  • Shows key statistics like possession, xG, and shots on target
  • Embeds video highlights with thumbnails right inside the match card
  • Displays venue, referee, and weather info for each game
  • Handles API errors properly with friendly messages and automatic poll stopping on quota limits
  • Supports a World Cup mode that filters to tournament matches only

We'll use the Highlightly Football API for all the data. It has a free tier with 100 requests per day. No credit card needed.

What you'll need

  • Node.js on your machine
  • A free API key from Highlightly
  • A text editor
  • About 20 minutes

Step 1. Grab your API key

Head to highlightly.net/login and create a free account. Your API key shows up right away.

Let's test it.

curl -X GET "https://soccer.highlightly.net/matches?date=2026-06-11" \
  -H "x-rapidapi-key: YOUR_API_KEY"
Enter fullscreen mode Exit fullscreen mode

You should get back a JSON response with a data array of matches. If you see data, we're good. If you get a 401, double check your API key.

Step 2. Set up the project

mkdir worldcup-tracker
cd worldcup-tracker
npm init -y
npm install express
mkdir public
Enter fullscreen mode Exit fullscreen mode

We'll end up with three files.

worldcup-tracker/
  server.js
  public/index.html
  package.json
Enter fullscreen mode Exit fullscreen mode

Step 3. Build the backend

Our server needs to fetch matches, match details, and video highlights from the Highlightly API. There are a few things it needs to handle that aren't obvious at first.

The API paginates results. Matches return up to 100 per page, highlights up to 40. A busy day can have hundreds of matches, so we need to loop through all pages. The built-in fetch doesn't throw on 4xx responses, so a quota error or bad API key would silently return an error body instead of real data. We need to catch those. And we want a configurable mode so we can show all leagues or filter to World Cup only.

Create server.js.

const express = require("express");
const app = express();

const API_BASE = "https://soccer.highlightly.net";
const API_KEY = process.env.HIGHLIGHTLY_API_KEY || "YOUR_API_KEY";
const MODE = (process.env.TRACKER_MODE || "all").toLowerCase();
const WORLD_CUP_ID = 1635;
const HEADERS = { "x-rapidapi-key": API_KEY };

// Page size limits per endpoint (from API docs)
const PAGE_LIMITS = { matches: 100, highlights: 40 };
const MAX_PAGES = 50;

app.use(express.static("public"));

// --- Helpers ---

function buildUrl(endpoint, params) {
  const filter = MODE === "worldcup" ? `leagueId=${WORLD_CUP_ID}` : "";
  const query = [params, filter].filter(Boolean).join("&");
  return `${API_BASE}/${endpoint}?${query}`;
}

async function apiFetch(url) {
  const res = await fetch(url, { headers: HEADERS });

  if (!res.ok) {
    const text = await res.text();
    let parsed;
    try { parsed = JSON.parse(text); } catch { parsed = text; }

    const msg = parsed?.message || parsed?.error || String(parsed);
    const remaining = res.headers.get("x-ratelimit-requests-remaining");

    console.error(`API ${res.status} ${url.split("?")[0]} | ${msg}` +
      (remaining != null ? ` | remaining: ${remaining}` : ""));

    const err = new Error(msg);
    err.status = res.status;
    throw err;
  }

  return res.json();
}

async function fetchAllPages(endpoint, params) {
  const limit = PAGE_LIMITS[endpoint] || 100;
  const baseUrl = buildUrl(endpoint, params);
  let all = [];

  for (let page = 0; page < MAX_PAGES; page++) {
    const offset = page * limit;
    const json = await apiFetch(`${baseUrl}&limit=${limit}&offset=${offset}`);
    const items = json.data || [];
    all = all.concat(items);

    if (offset + limit >= (json.pagination?.totalCount || 0)
      || items.length === 0) break;
  }

  return all;
}

function sendError(res, err, fallback) {
  const status = err.status || 500;
  const messages = {
    401: "Invalid API key. Check your HIGHLIGHTLY_API_KEY.",
    403: "API quota exceeded. The free tier allows 100 requests per day.",
    429: "API quota exceeded. The free tier allows 100 requests per day.",
  };
  res.status(status).json({
    error: messages[status] || fallback,
    detail: err.message,
  });
}

// --- Routes ---

app.get("/api/config", (_req, res) => {
  res.json({ mode: MODE });
});

app.get("/api/matches", async (req, res) => {
  const date = req.query.date || new Date().toISOString().split("T")[0];
  try {
    res.json({ data: await fetchAllPages("matches", `date=${date}`) });
  } catch (err) {
    sendError(res, err, "Failed to fetch matches");
  }
});

app.get("/api/matches/:id", async (req, res) => {
  try {
    res.json(await apiFetch(`${API_BASE}/matches/${req.params.id}`));
  } catch (err) {
    sendError(res, err, "Failed to fetch match details");
  }
});

app.get("/api/highlights", async (req, res) => {
  const date = req.query.date || new Date().toISOString().split("T")[0];
  try {
    res.json({ data: await fetchAllPages("highlights", `date=${date}`) });
  } catch (err) {
    sendError(res, err, "Failed to fetch highlights");
  }
});

// --- Start ---

app.listen(3000, () => {
  const modeLabel = MODE === "worldcup"
    ? "World Cup 2026 only" : "all leagues";
  console.log(
    `Live Score Tracker running at http://localhost:3000 [${modeLabel}]`
  );
});
Enter fullscreen mode Exit fullscreen mode

Let's walk through the interesting parts.

apiFetch wraps every API call. The key detail is that fetch doesn't throw on 4xx responses. Without this wrapper, a 429 (quota exceeded) or 401 (bad API key) would silently return an error JSON body, find no data array, and the app would just show zero matches. No error, no log. Our wrapper reads the body as text first (to avoid the "body already consumed" bug if JSON parsing fails), logs the status and the x-ratelimit-requests-remaining header, and throws with the status attached so the route handler can forward it.

fetchAllPages handles pagination. The matches endpoint caps at 100 per page, highlights at 40 (trying to use 100 for highlights returns a 400). The helper reads the right limit from PAGE_LIMITS, loops with an offset, and stops when offset exceeds pagination.totalCount or a page returns empty. A hard cap of 50 iterations prevents infinite loops if the API returns bad pagination data.

buildUrl switches between World Cup mode and all-leagues mode. When TRACKER_MODE=worldcup, it adds leagueId=1635 to every request. When the mode is all (the default), it leaves the filter off and you see every match across 950+ leagues.

Step 4. Build the frontend

Create public/index.html. The full file is around 600 lines, so we'll focus on the parts that matter and link to the complete source at the bottom.

How the API structures match data

Before we render anything, it helps to understand the response shape. A match object from the API looks like this.

match.state.description   "Not started", "First half", "Half time", "Finished", etc.
match.state.score.current  "2 - 1" (string) or null if not started
match.state.clock          67 (current minute) or null
match.homeTeam.name        "Arsenal"
match.homeTeam.logo        URL or null
match.date                 "2026-06-11T19:00:00.000Z"
match.league.name          "Premier League"
Enter fullscreen mode Exit fullscreen mode

The score is a single string with both teams ("2 - 1"), not separate fields. We split it.

function parseScore(match) {
  const raw = match?.state?.score?.current;
  if (!raw) return { home: "0", away: "0" };
  const [h, a] = String(raw).split("-").map(s => s.trim());
  return { home: h || "0", away: a || "0" };
}
Enter fullscreen mode Exit fullscreen mode

The ?. (optional chaining) throughout is important. If a match has a malformed or missing state object, we get a safe fallback instead of a crash.

Match cards

Each match gets a card showing the league logo, kickoff time, team logos, score, and status. Teams without logos get an SVG shield placeholder.

container.innerHTML = matches.map(match => {
  const sc = parseScore(match);
  const d = match?.state?.description || "";
  const noScore = d.toLowerCase() === "not started" || !hasScore(match);

  return `
  <div class="match-card" onclick="toggleDetail('${match.id}')">
    <div class="match-meta">
      <div class="match-league">
        ${leagueLogo(match.league)}${match.league?.name}
      </div>
      <div class="match-time">${fmtTime(match.date)}</div>
    </div>
    <div class="match-teams">
      <div class="team home">
        ${teamLogo(match.homeTeam)}
        <span>${match.homeTeam?.name || "TBD"}</span>
      </div>
      <div class="score-box">
        <div class="score" id="score-${match.id}">
          ${noScore ? "vs" : `${sc.home} - ${sc.away}`}
        </div>
        <div class="match-status" id="status-${match.id}">
          ${statusLabel(match)}
        </div>
      </div>
      <div class="team away">
        <span>${match.awayTeam?.name || "TBD"}</span>
        ${teamLogo(match.awayTeam)}
      </div>
    </div>
  </div>`;
}).join("");
Enter fullscreen mode Exit fullscreen mode

The id attributes on the score and status elements are there for live updates. We'll come back to that.

The event timeline

When you click a match, we fetch the full details from /matches/:id. The detail response includes an events array where each event has time, type, player, team.id, and substituted (for subs).

We render these on a center-spine timeline with home team events on the left and away events on the right, determined by comparing event.team.id to match.homeTeam.id.

const homeId = match.homeTeam?.id;

events.map(e => {
  const cls = evClass(e.type);  // "goal", "yellow-card", "substitution", etc.
  const side = e.team?.id === homeId ? "home" : "away";

  let body;
  if (cls === "substitution" && e.substituted) {
    body = `<div class="tl-name">${e.substituted}</div>
            <div class="tl-sub">
              <span class="tl-sub-in">▲ ${e.player}</span>
            </div>`;
  } else {
    body = `<div class="tl-name">${e.player}</div>`;
    if (cls === "goal" && e.assist)
      body += `<div class="tl-sub">Assist: ${e.assist}</div>`;
  }

  return `
  <div class="tl-ev ${side}">
    <div class="tl-dot ${cls}"></div>
    <div class="tl-card ${cls === 'goal' ? 'goal' : ''}">
      <div class="tl-head">
        <span class="tl-min">${e.time}'</span>
        <span class="tl-tag ${cls}">${evLabel(e.type)}</span>
      </div>
      ${body}
    </div>
  </div>`;
});
Enter fullscreen mode Exit fullscreen mode

Goals get a green dot with a glow and a green-tinted card. Substitutions show the outgoing player as the main name with the incoming player below. Each event type (goal, yellow card, red card, substitution) gets its own color on the timeline dot and label badge.

Statistics

The detail response includes a statistics array with two entries (one per team). Each entry has an array of { displayName, value } objects. We pick the most useful stats and render them with proportional bars.

const keys = ["Possession", "Shots on target", "Expected Goals",
              "Corners", "Fouls", "Successful passes"];

rows.map(r => `
  <div class="stat-item">
    <div class="stat-head">
      <span class="stat-val">${r.home}</span>
      <span class="stat-label">${r.name}</span>
      <span class="stat-val">${r.away}</span>
    </div>
    <div class="stat-bar">
      <div class="stat-home" style="width:${r.pct}%"></div>
      <div class="stat-away"></div>
    </div>
  </div>`);
Enter fullscreen mode Exit fullscreen mode

Possession comes from the API as a decimal (0.53 for 53%), so we multiply by 100. The bar width is the home team's share of the combined total.

Video highlights

Most football APIs stop at scores and stats. Highlightly also returns video highlight URLs alongside the match data. Goals, saves, red cards, full recaps. Each highlight has a title, url, type (VERIFIED or UNVERIFIED), source (youtube, reddit, or other), an optional imgUrl thumbnail, and a match.id field we use to link it to the right game.

const hl = highlights.filter(h => h.match?.id === Number(matchId));

hl.map(h => `
  <a class="hl-card" href="${h.url}" target="_blank">
    ${h.imgUrl
      ? `<img class="hl-thumb" src="${h.imgUrl}" />`
      : `<div class="hl-thumb-ph">${PLAY_SVG}</div>`}
    <div class="hl-info">
      <div class="hl-title">${h.description || h.title}</div>
      <div class="hl-meta">
        <span class="hl-badge ${(h.type || '').toLowerCase()}">${h.type}</span>
        <span class="hl-source">${h.source}</span>
      </div>
    </div>
  </a>`);
Enter fullscreen mode Exit fullscreen mode

Live score updates without breaking open panels

The app polls for new data every 60 seconds. The naive approach is to call renderMatches() which rebuilds the entire DOM. The problem is that any expanded detail panel gets destroyed and collapses.

Instead, the poll calls updateScores() which finds each match's score and status elements by their id attributes and updates them in place. The DOM stays intact.

function updateScores(matches) {
  // If match count changed, fall back to full re-render
  const currentCards = document.querySelectorAll(".match-card").length;
  if (matches.length !== currentCards) {
    renderMatches(matches);
    return;
  }

  for (const match of matches) {
    const scoreEl = $(`score-${match.id}`);
    const statusEl = $(`status-${match.id}`);
    if (!scoreEl || !statusEl) continue;

    const sc = parseScore(match);
    const d = match?.state?.description || "";
    const noScore = d.toLowerCase() === "not started" || !hasScore(match);

    scoreEl.innerHTML = noScore ? `<span class="score-vs">vs</span>`
      : `${sc.home} - ${sc.away}`;
    statusEl.textContent = statusLabel(match);
    statusEl.className = `match-status ${statusClass(d)}`;
  }
}
Enter fullscreen mode Exit fullscreen mode

If the number of matches changes (a new game appears or one disappears), it falls back to a full re-render since the card list needs rebuilding. On quota errors (429, 403), the poll stops automatically.

Error handling on the frontend

When the server returns an error, the frontend shows the message instead of an empty match list.

if (!matchRes.ok) {
  const err = await matchRes.json().catch(() => ({}));
  $("matches").innerHTML = renderEmpty(
    "",
    err.error || `API returned ${matchRes.status}`,
    err.detail || "Check the server console"
  );
  if (matchRes.status === 429 || matchRes.status === 403) stopPolling();
  return;
}
Enter fullscreen mode Exit fullscreen mode

A 429 shows "API quota exceeded." A 401 shows "Invalid API key." Polling stops on quota errors so you don't burn requests on a dead quota.

Step 5. Run it

HIGHLIGHTLY_API_KEY=your_key_here node server.js
Enter fullscreen mode Exit fullscreen mode

On Windows PowerShell:

$env:HIGHLIGHTLY_API_KEY="your_key_here"; node server.js
Enter fullscreen mode Exit fullscreen mode

Open http://localhost:3000. By default the app shows all football matches across all leagues. To filter to World Cup 2026 only:

$env:HIGHLIGHTLY_API_KEY="your_key"; $env:TRACKER_MODE="worldcup"; node server.js
Enter fullscreen mode Exit fullscreen mode

The World Cup runs June 11 to July 19, 2026. Before then, use the default all mode to test with live data from other leagues.

What you could build next

The API has a lot more data we didn't use.

Odds. The /odds endpoint returns pre-match and live odds from 100+ bookmakers.

Lineups. The /lineups endpoint returns confirmed starting XIs and formations before kickoff.

Predictions. The match detail response already includes a predictions field with win probabilities that update throughout the game.

Player stats. The /players endpoint returns career stats, season averages, and transfer history.

Group standings. The /standings endpoint returns World Cup group tables.

More sports. Highlightly covers 9 sports total. NBA, NFL, MLB, NHL, cricket, rugby, volleyball, and handball all follow the same API structure.

Check the full API documentation for details on every endpoint.

Wrapping up

We built a live score tracker with match event timelines, statistics, video highlights, and venue info. The backend is about 100 lines. The frontend is a single HTML file with no build step and no framework.

The video highlights are what sold us on this API. We tried a few football data providers while putting this together, and Highlightly is the only one that bundles verified video highlights alongside structured match data. Goals, saves, red cards, full recaps, press conferences. All in the same response as the scores and stats.

Useful links

Happy building, and enjoy the World Cup!


This article was written with the assistance of AI and reviewed for technical accuracy. All code examples use the live Highlightly Football API endpoints and were verified against the API documentation.

Top comments (1)

Collapse
 
sports_junkie profile image
Highlightly Highlightly

The article was written with Highlightly in mind. However, there is an option to go with RapidAPI. You will need to create an account there and use their API Key. The base URL is also a bit different. Other than that, there are no differences in the retrieved data