guide
"Build a CS2 live-scores dashboard with Next.js"
Jul 10, 2026
Bottom line: A CS2 scoreboard is a short Next.js app: fetch
GET /v1/cs2/matches?status=liveon the server so your API key never ships to the browser, render each row straight from the names the list already includes, and refresh every 20–30 seconds. Two things to design around: there are no team logos (logo_urlis always empty, so use text badges), and the freshness indicator is a status signal, not a countdown. Built against the EsportsOdds CS2 data API.
The architecture: keep the key on the server
The single most important decision is where your API key lives. It must stay on your server. The browser talks to your Next.js backend; your backend holds the key and talks to the API.
In the App Router this falls out naturally, because Server Components run only on the server. Read the key from the environment and it's never serialised to the client bundle:
// app/scores/page.tsx — a Server Component
const BASE = "https://api.esportsodds.gg";
async function getLiveMatches() {
const res = await fetch(`${BASE}/v1/cs2/matches?status=live`, {
headers: { Authorization: `Bearer ${process.env.ESPORTSODDS_API_KEY}` },
next: { revalidate: 20 }, // re-fetch at most every 20s
});
if (!res.ok) throw new Error(`API error: ${res.status}`);
const { data } = await res.json();
return data;
}
process.env.ESPORTSODDS_API_KEY (without the NEXT_PUBLIC_ prefix) is server-only. If you ever find yourself reaching for NEXT_PUBLIC_, stop — that would leak the key into the browser.
Render straight from the list
Because the matches list is the enriched projection, every row already carries team_a_name, team_b_name, score_a, score_b, and tournament_name. You can render a full scoreboard with no follow-up requests.
export default async function ScoresPage() {
const matches = await getLiveMatches();
return (
<ul>
{matches.map((m: Match) => (
<li key={m.id}>
<span>{m.team_a_short ?? m.team_a_name}</span>
<strong>{m.score_a ?? 0} — {m.score_b ?? 0}</strong>
<span>{m.team_b_short ?? m.team_b_name}</span>
<small>{m.tournament_name}</small>
</li>
))}
</ul>
);
}
Note the ?? 0 on the scores: score_a and score_b are null before a match produces a result, so give them a fallback.
There are no logos — use text badges
Reach for a team crest and you'll find logo_url is an empty string on every team. That's by design, not a gap in the data. So build your team badge from text — the short_name field ("NAVI", "FaZe") is exactly what you want.
function TeamBadge({ name, short }: { name: string; short?: string | null }) {
return <span className="badge">{short ?? name}</span>;
}
A monospace badge with the short name reads cleanly and never breaks on a missing image. It's also faster — no image requests at all.
Keeping it fresh
next: { revalidate: 20 } re-fetches on the server every 20 seconds, so a reload always shows recent scores. To update without a manual reload, refresh the route segment on a timer from a small Client Component:
"use client";
import { useRouter } from "next/navigation";
import { useEffect } from "react";
export function AutoRefresh({ seconds = 25 }: { seconds?: number }) {
const router = useRouter();
useEffect(() => {
const id = setInterval(() => router.refresh(), seconds * 1000);
return () => clearInterval(id);
}, [router, seconds]);
return null;
}
A word on presentation: keep the freshness cue calm. A small "updated 12s ago" line or a steady status dot tells people the data is current. Skip countdown timers or anything that pressures a decision — a scoreboard informs, it doesn't rush. If you later want changes to arrive without polling at all, the WebSocket guide covers pushing updates the moment a match changes.
Next steps
- Stream updates over WebSocket — replace the polling interval with a push connection.
- Handle rate limits — if many visitors hit your server, cache the upstream call so you stay well under the limits.
- Generate match previews — add a pre-game panel with head-to-head and form.
Fetch server-side, render from the names you already have, badge with text, and refresh gently. The endpoint reference has the full match schema.