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=live on 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_url is 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.

The browser fetches from your Next.js server, which holds the API key and calls the EsportsOdds API. The key never reaches the client.

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.

The dashboard fetches the live-matches list, which already includes team names, maps each row to a scoreboard entry, and refreshes on a short interval.

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.

A rendered match row: two teams as text badges — there are no logo URLs — with the live score between them and the tournament alongside.

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

Fetch server-side, render from the names you already have, badge with text, and refresh gently. The endpoint reference has the full match schema.