Build a Live World Cup Score Widget with Vanilla JavaScript
Step-by-step tutorial: build a live World Cup 2026 score widget with vanilla JavaScript and a REST API. Copy-paste embed code included.
A live World Cup score widget is the perfect embedable: every site that covers football wants one during the tournament, and most aren't built in-house. This tutorial walks through building a self-contained widget in vanilla JavaScript that any site can drop in with a <script> tag.
By the end you'll have:
- A self-updating widget showing today's World Cup 2026 matches with live scores
- An embed snippet you can paste into any HTML page
- No framework dependencies (works in WordPress, Squarespace, anywhere)
The plan
1. Build a small JS file that fetches today's matches and renders them
2. Polls every 30 seconds for score updates
3. Bundles into a single <script> tag with all dependencies
4. Hosts on a CDN (Vercel, Netlify, or your own)
5. Embeds with one line of HTML
Step 1: HTML target
Sites embed the widget by dropping in a target element:
<div id="wc2026-widget"></div>
<script async src="https://yourdomain.com/wc2026-widget.js"></script>
The script auto-discovers the #wc2026-widget div and mounts inside it.
Step 2: The widget script
// wc2026-widget.js
(function () {
const MOUNT_ID = 'wc2026-widget';
const API = 'https://api.thestatsapi.com/api/football/matches';
const COMPETITION_ID = 'YOUR_COMPETITION_ID'; // look up via GET /football/competitions?search=world%20cup
const POLL_MS = 30000;
const mount = document.getElementById(MOUNT_ID);
if (!mount) return;
// Inject minimal scoped styles
injectStyles();
// Render initial loading state
mount.innerHTML = `<div class="wc-widget wc-loading">Loading World Cup matches...</div>`;
// Initial fetch + interval
refresh();
setInterval(refresh, POLL_MS);
async function refresh() {
try {
const today = new Date().toISOString().slice(0, 10);
const url = `${API}?competition_id=${COMPETITION_ID}&date=${today}`;
const r = await fetch(url);
if (!r.ok) throw new Error('API error');
const { data } = await r.json();
render(data);
} catch (err) {
mount.innerHTML = `<div class="wc-widget wc-error">Couldn't load matches</div>`;
}
}
function render(matches) {
if (!matches.length) {
mount.innerHTML = `<div class="wc-widget wc-empty">No World Cup matches today</div>`;
return;
}
mount.innerHTML = `
<div class="wc-widget">
<div class="wc-header">FIFA World Cup 2026 — Today's Matches</div>
<ul class="wc-list">
${matches.map(matchRow).join('')}
</ul>
<a class="wc-attr" href="https://www.thestatsapi.com/world-cup" target="_blank" rel="noopener">
Powered by TheStatsAPI
</a>
</div>
`;
}
function matchRow(m) {
const status = m.status === 'in_progress'
? `<span class="wc-live">LIVE ${m.minute}'</span>`
: m.status === 'finished'
? `<span class="wc-final">FT</span>`
: `<span class="wc-time">${formatKickoff(m.kickoff_utc)}</span>`;
const score = (m.status === 'in_progress' || m.status === 'finished')
? `<span class="wc-score">${m.home_score} - ${m.away_score}</span>`
: '<span class="wc-vs">vs</span>';
return `
<li class="wc-match">
<span class="wc-team wc-home">${m.home.name || m.home.label}</span>
${score}
<span class="wc-team wc-away">${m.away.name || m.away.label}</span>
${status}
</li>
`;
}
function formatKickoff(iso) {
const d = new Date(iso);
return d.toLocaleTimeString(undefined, { hour: '2-digit', minute: '2-digit' });
}
function injectStyles() {
if (document.getElementById('wc-widget-styles')) return;
const style = document.createElement('style');
style.id = 'wc-widget-styles';
style.textContent = `
.wc-widget { font-family: system-ui, sans-serif; border: 1px solid #e5e7eb; border-radius: 8px; padding: 12px; max-width: 380px; background: #fff; }
.wc-header { font-weight: 700; font-size: 14px; margin-bottom: 8px; color: #111827; }
.wc-list { list-style: none; padding: 0; margin: 0; }
.wc-match { display: grid; grid-template-columns: 1fr auto 1fr 60px; gap: 8px; align-items: center; padding: 8px 0; border-top: 1px solid #f3f4f6; font-size: 13px; }
.wc-team { color: #111827; }
.wc-home { text-align: right; }
.wc-away { text-align: left; }
.wc-score { font-weight: 700; color: #111827; }
.wc-vs { color: #9ca3af; font-size: 11px; }
.wc-live { color: #dc2626; font-weight: 700; font-size: 11px; text-align: right; }
.wc-final { color: #6b7280; font-weight: 700; font-size: 11px; text-align: right; }
.wc-time { color: #6b7280; font-size: 11px; text-align: right; }
.wc-attr { display: block; margin-top: 8px; font-size: 10px; color: #9ca3af; text-align: center; text-decoration: none; }
.wc-attr:hover { color: #6b7280; }
`;
document.head.appendChild(style);
}
})();
Step 3: Authentication for public widgets
Public widgets are tricky because you can't expose your API key. Two options:
Option A — Public proxy endpoint (recommended)
Build a Vercel edge function that fetches from TheStatsAPI server-side and exposes a public endpoint with the API key hidden:
// api/wc-matches.js (Vercel)
const COMPETITION_ID = process.env.WORLD_CUP_COMPETITION_ID; // look up via GET /football/competitions?search=world%20cup
export default async function handler(req, res) {
const date = req.query.date || new Date().toISOString().slice(0, 10);
const upstream = await fetch(
`https://api.thestatsapi.com/api/football/matches?competition_id=${COMPETITION_ID}&date=${date}`,
{ headers: { Authorization: `Bearer ${process.env.STATS_API_KEY}` } }
);
const data = await upstream.json();
res.setHeader('Cache-Control', 's-maxage=20, stale-while-revalidate=60');
res.setHeader('Access-Control-Allow-Origin', '*');
res.status(200).json(data);
}
Then the widget calls https://yourdomain.com/api/wc-matches instead of TheStatsAPI directly.
Option B — Embeddable iframe
Render the widget on your own domain as a small page, then embed via <iframe> on third-party sites. Auth stays server-side, but iframes are harder to style.
Step 4: Host the widget
Drop wc2026-widget.js into /public on Vercel/Netlify. Boom — it's at https://yourdomain.com/wc2026-widget.js.
Edge-cache with Cache-Control: public, max-age=3600 for the JS file (it rarely changes). The data is cached separately by the proxy endpoint.
Step 5: The embed snippet
What you give to publishers:
<!-- Live FIFA World Cup 2026 scores -->
<div id="wc2026-widget"></div>
<script async src="https://yourdomain.com/wc2026-widget.js"></script>
That's it. Drop it into any HTML page and it works.
Step 6: Track usage
Add a Plausible / Fathom / GA snippet inside the widget script to track embeds and page views:
fetch('https://yourdomain.com/api/track', {
method: 'POST',
body: JSON.stringify({ event: 'widget_view', host: window.location.hostname }),
});
Useful for understanding which sites embed your widget and how often.
Adapt the widget for other use cases
The same pattern works for:
- Single-team widget —
?team=mexicofilter, shows just one nation's fixtures - Group widget —
?group=Afilter, shows one group's fixtures and standings - Top scorer ticker — different endpoint, same structure
- Match-day banner — show kickoff countdown for the next match
What about Cloudflare Pages / GitHub Pages?
Both work for the static JS file. The proxy endpoint needs a server (Vercel, Netlify Functions, Cloudflare Workers, or AWS Lambda). Cloudflare Workers is the cheapest at this scale — basically free for thousands of requests per day.
Frequently Asked Questions
How do I prevent third-party sites from using my API key?
Never expose the API key in client-side code. Always proxy requests through a server endpoint that adds the auth header server-side.
What about CORS?
Set Access-Control-Allow-Origin: * on your proxy endpoint to allow any third-party site to fetch. For a paid widget service, restrict to known domains.
How do I avoid rate limits when many sites embed the widget?
Cache aggressively at the proxy. The widget only needs fresh data every 20-30 seconds; cache for 20 seconds (Cache-Control: s-maxage=20) and you can serve thousands of widget loads per minute from a single upstream call.
Can I monetise this?
Yes — add affiliate links to betting sites in the widget footer, sell branded versions to media outlets, or just use it as a backlink magnet (every embed is a free backlink to your site).
How do I make the widget look good on dark backgrounds?
Use CSS variables for colours and provide a data-theme="dark" attribute on the mount div. The script reads it and swaps the colour palette.
Will the widget keep working after the World Cup?
Filter to the World Cup competition_id plus season=2026 and the widget always returns the current World Cup. For an evergreen widget, swap the competition_id to a domestic league when the tournament ends.
How do I handle the time zone?
The widget already does — kickoff_utc is converted to the user's local time via Intl.DateTimeFormat. No server-side time zone logic needed.
Ready to Power Your Sports App?
Start your 7-day free trial. All endpoints included on every plan.