Build a live football scoreboard
A scoreboard is the smallest useful thing you can build on football data, and it is the one most tutorials get wrong in a way that will leak your API key. Here is the whole thing, including the part they skip.
What live returns
One endpoint, no parameters:
curl -H "X-RapidAPI-Key: YOUR_KEY" \
-H "X-RapidAPI-Host: premier-league18.p.rapidapi.com" \
"https://premier-league18.p.rapidapi.com/matches/live"
When nothing is being played, you get this, and you should design for it first because it is the answer most of the time:
{ "data": [], "meta": { "season": "2026-27" } }
When a match is in play, each entry carries the score and the minute, plus both teams with a
crest URL you can use directly in an img tag.
Poll every 30 seconds
Our updater polls the source every 30 seconds while matches are in play. Requesting more often
than that cannot give you fresher data, it just spends your quota. The response also carries a
cache-control header of 15 seconds, which is the shortest interval at which a repeat
request can return anything new.
Thirty seconds is the right number. Sixty is fine for a background widget.
Your key cannot go in the browser
This is the part that gets skipped. If your page calls the API directly with
fetch, your key is in the page source, in the network tab, and in the hands of anyone
who opens developer tools. Keys get scraped from public sites automatically.
Put a small proxy in front. It is fifteen lines:
// server.js
import express from 'express';
const app = express();
const KEY = process.env.RAPIDAPI_KEY;
const HOST = 'premier-league18.p.rapidapi.com';
app.get('/api/live', async (_req, res) => {
const upstream = await fetch('https://' + HOST + '/matches/live', {
headers: { 'X-RapidAPI-Key': KEY, 'X-RapidAPI-Host': HOST },
});
// Let the browser cache briefly so a reload does not cost a call.
res.set('cache-control', 'public, max-age=15');
res.status(upstream.status).json(await upstream.json());
});
app.use(express.static('public'));
app.listen(3000);
The browser now talks to your server, your server holds the key, and your quota is spent once per 30 seconds no matter how many people have the page open.
The page
<!doctype html>
<ul id="board"></ul>
<script>
const board = document.getElementById('board');
const render = (matches) => {
if (matches.length === 0) {
board.innerHTML = '<li>No matches in play</li>';
return;
}
board.innerHTML = matches.map((m) =>
'<li>' +
'<img src="' + m.homeTeam.crestUrl + '" width="20" height="20" alt=""> ' +
m.homeTeam.name + ' ' + m.score.home + ' - ' + m.score.away + ' ' + m.awayTeam.name +
' <small>' + m.minute + "'</small>" +
'</li>'
).join('');
};
const tick = async () => {
try {
const res = await fetch('/api/live');
if (!res.ok) return; // keep the last good board on screen
render((await res.json()).data);
} catch { /* offline: leave what is there */ }
};
tick();
setInterval(tick, 30000);
</script>
Three things that will happen in production
The request will fail. Networks drop and upstreams have bad minutes. Notice that the code above leaves the previous scores on screen rather than blanking the board. A scoreboard that flashes empty on one failed poll looks broken even though nothing is wrong.
Nothing will be live. Most of the day, and most of the week, the array is empty. Handle it as a normal state with real copy, not as an error.
A match will end while someone is watching. A finished match drops out of
/matches/live on the next poll. If you want it to stay on screen, keep your own list
and mark entries as finished when they disappear, rather than replacing the array each time.
Where to go next
Once a match is interesting, /matches/{id} gives you the lineups, the events and a
rating per player. That is the subject of
the next article.