Build a live championship standings table
Show the MotoGP, Moto2 or Moto3 riders' championship on your website through a small caching proxy that keeps your RapidAPI key off the browser.
10 min read
A standings table is the most common MotoGP widget on news sites, fan blogs and club pages. This guide builds one in two parts:
- A proxy — a tiny server endpoint that holds your key and caches responses.
- A widget — plain HTML and JavaScript that renders the table from the proxy.
1. The proxy
The browser must never see your RapidAPI key, so the page talks to your own endpoint. This example uses Node.js 18+ and Express; the same 30 lines work as a Netlify, Vercel or Cloudflare function.
// server.js — npm install express
import express from "express";
const HOST = "motogp-api-championship-data-statistics.p.rapidapi.com";
const KEY = process.env.RAPIDAPI_KEY;
if (!KEY) throw new Error("RAPIDAPI_KEY is not set");
const CATEGORIES = new Set(["MOTOGP", "MOTO2", "MOTO3"]);
const TTL_MS = 60 * 60 * 1000; // 1 hour
const cache = new Map();
async function cached(path) {
const hit = cache.get(path);
if (hit && Date.now() - hit.at < TTL_MS) return hit.body;
const res = await fetch(`https://${HOST}${path}`, {
headers: { "X-RapidAPI-Key": KEY, "X-RapidAPI-Host": HOST },
});
if (!res.ok) throw new Error(`Upstream ${res.status}`);
const body = await res.json();
cache.set(path, { at: Date.now(), body });
return body;
}
const app = express();
app.get("/api/standings", async (req, res) => {
const category = String(req.query.category ?? "MOTOGP").toUpperCase();
if (!CATEGORIES.has(category)) return res.status(400).json({ error: "Unknown category" });
try {
const { data: season } = await cached("/seasons/latest");
const { data } = await cached(`/standings/seasons/${season.year}/riders?category=${category}`);
res.set("Cache-Control", "public, max-age=300").json({ season: season.year, category, rows: data });
} catch (err) {
res.status(502).json({ error: "Standings are unavailable right now" });
}
});
app.use(express.static("public"));
app.listen(3000, () => console.log("http://localhost:3000"));
Two details matter:
- Validate input. Only the three known categories are forwarded, so nobody can use your proxy to spend your quota on arbitrary endpoints.
- Cache. One upstream call per hour per category serves every visitor.
2. The widget
<!-- public/index.html -->
<div class="standings">
<div role="tablist">
<button data-category="MOTOGP" aria-selected="true">MotoGP</button>
<button data-category="MOTO2">Moto2</button>
<button data-category="MOTO3">Moto3</button>
</div>
<table>
<caption></caption>
<thead>
<tr><th>Pos</th><th>Rider</th><th>Team</th><th>Wins</th><th>Pts</th></tr>
</thead>
<tbody></tbody>
</table>
</div>
<script type="module">
const tbody = document.querySelector(".standings tbody");
const caption = document.querySelector(".standings caption");
function cell(text) {
const td = document.createElement("td");
td.textContent = text; // textContent, never innerHTML, for data from an API
return td;
}
async function load(category) {
caption.textContent = "Loading…";
const res = await fetch(`/api/standings?category=${category}`);
if (!res.ok) {
caption.textContent = "Standings are unavailable right now.";
tbody.replaceChildren();
return;
}
const { season, rows } = await res.json();
caption.textContent = `${season} ${category} riders' championship`;
tbody.replaceChildren(
...rows.map((r) => {
const tr = document.createElement("tr");
tr.append(
cell(r.position),
cell(`#${r.rider_number} ${r.rider_name}`),
cell(r.team_name),
cell(r.wins),
cell(r.total_points),
);
return tr;
}),
);
}
document.querySelectorAll("[data-category]").forEach((button) => {
button.addEventListener("click", () => {
document.querySelectorAll("[data-category]").forEach((b) => b.setAttribute("aria-selected", b === button));
load(button.dataset.category);
});
});
load("MOTOGP");
</script>
Run RAPIDAPI_KEY=... node server.js and open http://localhost:3000.
Variations
- Gap to the leader. Add a column with
rows[0].total_points - r.total_points. - Team championship. Point a second route at
/standings/seasons/{year}/teams— rows carryteam_nameandtotal_points. - Manufacturers.
/standings/seasons/{year}/manufacturersreturnsmanufacturer_namerows. - Static sites. Fetch the standings in your build step (Astro, Next.js, Hugo) and rebuild after each race weekend — no proxy needed at all.