What You're Building
A live match center that shows:
- Currently live LoL esports matches (LCS, LEC, LCK, LPL, etc.)
- Series score and current game context
- High-confidence live reads: kills, gold, towers, dragons, barons
- A simple auto-refresh loop you can later replace with webhooks
Stack: Next.js App Router, TypeScript, and the Cito LoL API.
Get a Free Key
curl -H "x-api-key: cito_live_your_key" \ https://api.citoapi.com/api/v1/lol/live
npx create-next-app@latest lol-match-center --typescript --app --tailwindcd lol-match-center
CITO_API_KEY=cito_live_your_key
// lib/cito.tsconst BASE = "https://api.citoapi.com/api/v1"; export async function citoGet<T>(path: string): Promise<T> { const res = await fetch(`${BASE}${path}`, { headers: { "x-api-key": process.env.CITO_API_KEY! }, next: { revalidate: 5 }, // short ISR while prototyping; swap to webhooks later }); if (!res.ok) { throw new Error(`Cito ${res.status}: ${path}`); } return res.json();}
Fetch Live Matches (With Schedule Fallback)
When matches are live, list them with series scores. When the slate is quiet, fall back to today's schedule.
// app/page.tsximport Link from "next/link";import { citoGet } from "@/lib/cito"; type LiveMatch = { matchId: string; league?: string; format?: string; blueTeam?: { tag?: string; name?: string; seriesScore?: number }; redTeam?: { tag?: string; name?: string; seriesScore?: number };}; type ScheduleMatch = { matchId: string; league?: string; state?: string; startTime?: string; team1?: { name?: string; slug?: string }; team2?: { name?: string; slug?: string };}; export default async function MatchCenterPage() { const livePayload = await citoGet<{ data?: LiveMatch[] }>("/lol/live"); const matches = livePayload.data ?? []; const schedulePayload = matches.length === 0 ? await citoGet<{ data?: ScheduleMatch[]; matches?: ScheduleMatch[] }>( "/lol/schedule/today" ) : null; const today = schedulePayload?.data ?? schedulePayload?.matches ?? []; return ( <main className="mx-auto max-w-5xl px-4 py-10"> <header className="flex items-end justify-between gap-4"> <div> <h1 className="text-3xl font-semibold tracking-tight">LoL Live Match Center</h1> <p className="mt-2 text-zinc-500"> {matches.length > 0 ? "In-progress esports matches" : "Nothing live — today's schedule"} </p> </div> <p className="text-xs text-zinc-500">Refreshes every ~5s (ISR)</p> </header> {matches.length > 0 ? ( <ul className="mt-8 space-y-3"> {matches.map((m) => ( <li key={m.matchId}> <Link href={`/match/${m.matchId}`} className="flex items-center justify-between rounded-2xl border border-zinc-800 bg-zinc-950 px-4 py-4 hover:border-zinc-600" > <div> <p className="text-xs uppercase tracking-wide text-zinc-500"> {m.league ?? "LoL"} · {m.format ?? "Series"} </p> <p className="mt-1 text-lg font-medium"> {m.blueTeam?.tag ?? m.blueTeam?.name ?? "Blue"}{" "} <span className="text-zinc-500">vs</span>{" "} {m.redTeam?.tag ?? m.redTeam?.name ?? "Red"} </p> </div> <p className="font-mono text-xl tabular-nums"> {m.blueTeam?.seriesScore ?? 0} – {m.redTeam?.seriesScore ?? 0} </p> </Link> </li> ))} </ul> ) : ( <ul className="mt-8 space-y-3"> {today.map((m) => ( <li key={m.matchId} className="flex items-center justify-between rounded-2xl border border-zinc-800 px-4 py-4" > <div> <p className="text-xs uppercase tracking-wide text-zinc-500"> {m.league ?? "LoL"} · {m.state ?? "upcoming"} </p> <p className="mt-1 font-medium"> {m.team1?.name ?? "TBD"} vs {m.team2?.name ?? "TBD"} </p> </div> <p className="text-sm text-zinc-400"> {m.startTime ? new Date(m.startTime).toLocaleTimeString([], { hour: "numeric", minute: "2-digit", }) : "—"} </p> </li> ))} </ul> )} </main> );}
Scoreboard Component
Header (league + series), kill totals, gold bar, objectives, and game clock — driven by /series + /visual-state.
// components/scoreboard.tsximport { formatClock, formatGold, goldPercent } from "@/lib/format"; export type SeriesState = { league?: string; format?: string; // BO3 / BO5 blueScore?: number; redScore?: number; currentGameNumber?: number; blueTeam?: { tag?: string; name?: string }; redTeam?: { tag?: string; name?: string };}; export type VisualState = { gameTimeSeconds?: number; blueTeam?: { tag?: string; gold?: number; kills?: number; towers?: number; dragons?: number; barons?: number; }; redTeam?: { tag?: string; gold?: number; kills?: number; towers?: number; dragons?: number; barons?: number; };}; function Objective({ label, blue, red,}: { label: string; blue: number; red: number;}) { return ( <div className="flex items-center justify-between gap-3 rounded-xl bg-zinc-900/80 px-3 py-2 text-sm"> <span className="font-mono tabular-nums text-sky-400">{blue}</span> <span className="text-xs uppercase tracking-wide text-zinc-500">{label}</span> <span className="font-mono tabular-nums text-rose-400">{red}</span> </div> );} export function Scoreboard({ series, visual,}: { series: SeriesState; visual: VisualState | null;}) { const blueTag = visual?.blueTeam?.tag ?? series.blueTeam?.tag ?? "BLU"; const redTag = visual?.redTeam?.tag ?? series.redTeam?.tag ?? "RED"; const blueGold = visual?.blueTeam?.gold ?? 0; const redGold = visual?.redTeam?.gold ?? 0; const diff = blueGold - redGold; const blueShare = goldPercent(blueGold, redGold); return ( <section className="overflow-hidden rounded-3xl border border-zinc-800 bg-zinc-950"> {/* Header — league + BO3/BO5 series score */} <div className="flex flex-wrap items-center justify-between gap-3 border-b border-zinc-800 px-5 py-4"> <div> <p className="text-xs uppercase tracking-[0.18em] text-zinc-500"> {series.league ?? "LoL"} · {series.format ?? "Series"} {series.currentGameNumber ? ` · Game ${series.currentGameNumber}` : ""} </p> <p className="mt-1 text-lg font-semibold"> {series.blueTeam?.name ?? blueTag}{" "} <span className="text-zinc-500">vs</span>{" "} {series.redTeam?.name ?? redTag} </p> </div> <div className="text-right"> <p className="text-xs uppercase tracking-wide text-zinc-500">Series</p> <p className="font-mono text-3xl tabular-nums"> {series.blueScore ?? 0} <span className="mx-2 text-zinc-600">–</span> {series.redScore ?? 0} </p> </div> </div> <div className="space-y-6 px-5 py-6"> {/* Clock — game time from visual-state */} <div className="flex items-center justify-between"> <span className="text-xs uppercase tracking-wide text-zinc-500">Game clock</span> <span className="font-mono text-2xl tabular-nums"> {formatClock(visual?.gameTimeSeconds ?? 0)} </span> </div> {/* Scoreboard — blue / red kills + gold diff */} <div className="grid grid-cols-[1fr_auto_1fr] items-center gap-4"> <div className="text-left"> <p className="text-sm font-semibold text-sky-400">{blueTag}</p> <p className="mt-1 font-mono text-4xl tabular-nums"> {visual?.blueTeam?.kills ?? 0} </p> <p className="text-xs text-zinc-500">kills</p> </div> <div className="text-center"> <p className="text-xs uppercase tracking-wide text-zinc-500">Gold</p> <p className={`mt-1 font-mono text-lg tabular-nums ${ diff >= 0 ? "text-sky-400" : "text-rose-400" }`} > {diff >= 0 ? "+" : ""} {formatGold(diff)} </p> </div> <div className="text-right"> <p className="text-sm font-semibold text-rose-400">{redTag}</p> <p className="mt-1 font-mono text-4xl tabular-nums"> {visual?.redTeam?.kills ?? 0} </p> <p className="text-xs text-zinc-500">kills</p> </div> </div> {/* Gold bar */} <div> <div className="mb-2 flex justify-between text-xs text-zinc-500"> <span>{formatGold(blueGold)}</span> <span>{formatGold(redGold)}</span> </div> <div className="flex h-3 overflow-hidden rounded-full bg-zinc-900"> <div className="bg-sky-500" style={{ width: `${blueShare}%` }} /> <div className="bg-rose-500" style={{ width: `${100 - blueShare}%` }} /> </div> </div> {/* Objectives — towers, dragons, barons */} <div className="grid gap-2 sm:grid-cols-3"> <Objective label="Towers" blue={visual?.blueTeam?.towers ?? 0} red={visual?.redTeam?.towers ?? 0} /> <Objective label="Dragons" blue={visual?.blueTeam?.dragons ?? 0} red={visual?.redTeam?.dragons ?? 0} /> <Objective label="Barons" blue={visual?.blueTeam?.barons ?? 0} red={visual?.redTeam?.barons ?? 0} /> </div> </div> </section> );}
// lib/format.tsexport function formatClock(totalSeconds: number) { const m = Math.floor(totalSeconds / 60); const s = Math.floor(totalSeconds % 60); return `${m}:${s.toString().padStart(2, "0")}`;} export function formatGold(value: number) { const abs = Math.abs(value); if (abs >= 1000) return `${(value / 1000).toFixed(1)}k`; return String(value);} export function goldPercent(blue: number, red: number) { const total = blue + red; if (total <= 0) return 50; return Math.round((blue / total) * 100);}
// app/match/[matchId]/page.tsximport Link from "next/link";import { citoGet } from "@/lib/cito";import { Scoreboard, type SeriesState, type VisualState } from "@/components/scoreboard"; type ApiEnvelope<T> = { data?: T } & T; export default async function MatchPage({ params,}: { params: Promise<{ matchId: string }>;}) { const { matchId } = await params; // Pre-check before treating the match as live-state ready await citoGet(`/lol/matches/${matchId}/coverage`); const seriesRes = await citoGet<ApiEnvelope<SeriesState>>( `/lol/live/${matchId}/series` ); const series = (seriesRes.data ?? seriesRes) as SeriesState & { currentGameId?: string; gameId?: string; }; const gameId = series.currentGameId ?? series.gameId; const visualRes = gameId ? await citoGet<ApiEnvelope<VisualState>>(`/lol/live/${gameId}/visual-state`) : null; const visual = visualRes ? ((visualRes.data ?? visualRes) as VisualState) : null; return ( <main className="mx-auto max-w-5xl px-4 py-10"> <Link href="/" className="text-sm text-zinc-500 hover:text-zinc-300"> ← All matches </Link> <div className="mt-6"> <Scoreboard series={series} visual={visual} /> </div> <p className="mt-6 text-sm text-zinc-500"> Prototyping with short ISR refresh. For production, push updates with{" "} <Link href="/blog/lol-esports-webhooks-guide/" className="underline"> LoL live webhooks </Link>{" "} instead of polling. </p> </main> );}