Build a LoL Live Match Center in Next.js

Build a live League of Legends match center with Next.js — live scores, gold diff, objectives, and series state using the Cito LoL esports API.

Author:
Cito Team

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

bash
curl -H "x-api-key: cito_live_your_key" \  https://api.citoapi.com/api/v1/lol/live

Get your free API key — 500 calls/month to start.

Endpoints You'll Use

EndpointPurpose
GET /lol/liveCurrently in-progress matches only
GET /lol/live/{matchId}/seriesSeries score + current game context
GET /lol/live/{gameId}/visual-stateGame time, kills, gold, towers, dragons, barons
GET /lol/matches/{matchId}/coveragePre-check live-state / webhook eligibility
GET /lol/schedule/todayToday's slate when nothing is live

Project Setup

bash
npx create-next-app@latest lol-match-center --typescript --app --tailwindcd lol-match-center

Add your key to .env.local:

bash
CITO_API_KEY=cito_live_your_key

API Helper

TypeScript
// 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.

LoL Live Match Center

In-progress esports matches

~5s ISR

  • LCKBO5
    Gen.G

    1 – 1

    T1
  • LCSBO3
    Cloud9

    0 – 1

    Team Liquid
Live match list from GET /lol/live

LoL Live Match Center

Nothing live — today's schedule

~5s ISR

  • LECupcoming
    G2 Esports

    2:00 PM

    Fnatic
  • LPLupcoming
    Bilibili Gaming

    5:00 AM

    JD Gaming
Empty live night falls back to GET /lol/schedule/today

Implementation

TypeScript
// 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.

LCKBO5 · Game 3

Series

11

Gen.G

GEN

Clock

30:24

T1

T1

12

kills

+4.1k

9

kills

58.2k54.1k
6Towers4
3Dragons1
1Barons0
Example scoreboard UI with real team logos and objective icons.

Implementation

TypeScript
// 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>  );}

Helpers:

TypeScript
// 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);}

Match Page

Wire coverage → series → visual-state into the scoreboard.

TypeScript
// 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>  );}

What You Should See

  • Header — league, BO3/BO5, series score
  • ClockgameTimeSeconds from visual-state
  • Scoreboard — blue/red tags, kills, signed gold diff
  • Gold bar — proportional blue vs red gold
  • Objectives — towers, dragons, barons
  • Empty live night — homepage falls back to /lol/schedule/today
  • Next Steps

    • Swap ISR polling for LoL live webhooks
    • Add team logos from match/team payloads
    • Harden empty, error, and stale (freshness.isFresh === false) states
    • Mobile pass: stack the kill columns under ~480px

    Start building with esports data today

    Free tier available. Same API shape across Call of Duty, Fortnite, LoL, Dota 2, and more.