TUTORIAL

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.

6 min read

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=mexico filter, shows just one nation's fixtures
  • Group widget?group=A filter, 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.

Start building today

Ready to Power Your Sports App?

Start your 7-day free trial. All endpoints included on every plan.

Cancel anytime
7-day free trial
Setup in 5 minutes