Build a live scoreboard with Next.js

A small Next.js app: a server helper calls the API with a 30-second cache, a route handler serves trimmed JSON, and a client component refreshes the board every 60 seconds while the tab is visible. The key never reaches the browser.

Stack
Next.js 14, React, Vercel
Plan
Free to build; Starter or Growth for an always-on public site

Updated 2026-10-07. Tested against the live API on that date. Data is aggregated from public sources and is typically 20-30 seconds behind live play (about 1 second after our source). There is no SLA.

1. Get a free API key

Sign up (no card) and copy the key from the dashboard. The Free plan gives you 1,000 calls in the first 30 days, then 125 a month, at 1 request per second. Every successful REST call counts as one call.

Check the key works with one call to the live-scores endpoint:

Terminal

export REALTIME_SPORTS_API_KEY=your_key
curl -H "Authorization: Bearer $REALTIME_SPORTS_API_KEY" \
  "https://www.realtimesportsapi.com/api/v1/sports/football/leagues/nfl/events/live"

2. Get the code

Everything below is in the nextjs-scoreboard/ folder of the examples repo (MIT). Clone it and work from that folder.

Terminal

git clone https://github.com/ElcoDevRepos/realtime-sports-api-examples
cd realtime-sports-api-examples/nextjs-scoreboard

3. Server-side API helper with caching

lib/rsa.js is the only file that reads the key. fetch(url, { next: { revalidate: 30 } }) makes Next.js cache each league's scoreboard for 30 seconds, so however many visitors you have, the app makes at most about two API calls a minute per league. An allowlist of leagues stops the route from being used as an open proxy.

nextjs-scoreboard/lib/rsa.js

// Server-side helper for the Realtime Sports API. Only import this from server code (route
// handlers and server components): it reads the secret API key.
const API_BASE = process.env.RSA_API_BASE || 'https://www.realtimesportsapi.com/api/v1';

// Upstream responses are cached by Next.js for 30 s per URL, so any number of visitors costs
// at most ~2 API calls per minute per league. Data is typically 20-30 s behind live anyway.
export const REVALIDATE_SECONDS = 30;

// Only these leagues can be requested, so the route can't be used as an open proxy.
export const LEAGUES = {
  nfl: { sport: 'football', name: 'NFL' },
  'college-football': { sport: 'football', name: 'College Football' },
  nba: { sport: 'basketball', name: 'NBA' },
  'mens-college-basketball': { sport: 'basketball', name: "Men's College Basketball" },
  mlb: { sport: 'baseball', name: 'MLB' },
  nhl: { sport: 'hockey', name: 'NHL' },
  'eng.1': { sport: 'soccer', name: 'Premier League' },
  'usa.1': { sport: 'soccer', name: 'MLS' }
};

export class ApiError extends Error {
  constructor(status, message) {
    super(message);
    this.status = status;
  }
}

/** Current scoreboard window (recent finals, live and upcoming games) for a league. */
export async function getScoreboard(league) {
  const cfg = LEAGUES[league];
  if (!cfg) throw new ApiError(400, `Unknown league "${league}"`);
  const key = process.env.REALTIME_SPORTS_API_KEY;
  if (!key || key === 'your_api_key_here') throw new ApiError(500, 'REALTIME_SPORTS_API_KEY is not set on the server');

  const res = await fetch(`${API_BASE}/sports/${cfg.sport}/leagues/${league}/events?limit=100`, {
    headers: { Authorization: `Bearer ${key}` },
    next: { revalidate: REVALIDATE_SECONDS }
  });
  const body = await res.json().catch(() => null);
  if (!res.ok) throw new ApiError(res.status === 429 ? 429 : 502, body?.error?.message ?? `Upstream HTTP ${res.status}`);

  // Return only what the page needs (smaller payload, nothing secret).
  const games = (body.data ?? []).map((e) => ({
    id: e.id,
    date: e.date,
    shortName: e.shortName,
    state: e.status?.state ?? 'pre',
    detail: e.status?.detail ?? '',
    venue: e.venue?.name ?? null,
    home: { abbr: e.homeTeam?.abbreviation, name: e.homeTeam?.name, score: e.homeTeam?.score ?? null, winner: !!e.homeTeam?.winner },
    away: { abbr: e.awayTeam?.abbreviation, name: e.awayTeam?.name, score: e.awayTeam?.score ?? null, winner: !!e.awayTeam?.winner }
  }));
  games.sort((a, b) => Date.parse(a.date) - Date.parse(b.date));
  return { league, sport: cfg.sport, name: cfg.name, fetchedAt: new Date().toISOString(), games };
}

4. The route handler

GET /api/scoreboard?league=nfl returns the trimmed scoreboard with Cache-Control: s-maxage=30; unknown leagues get a 400.

nextjs-scoreboard/app/api/scoreboard/route.js

// GET /api/scoreboard?league=nfl
// Proxies the Realtime Sports API so the key never reaches the browser. The upstream fetch is
// cached for 30 s (see lib/rsa.js), and browsers/CDNs may cache this response for 30 s too.
import { NextResponse } from 'next/server';
import { getScoreboard } from '../../../lib/rsa';

export async function GET(request) {
  const league = request.nextUrl.searchParams.get('league') || 'nfl';
  try {
    const data = await getScoreboard(league);
    return NextResponse.json(data, {
      headers: { 'Cache-Control': 'public, s-maxage=30, stale-while-revalidate=30' }
    });
  } catch (err) {
    return NextResponse.json({ error: err.message }, { status: err.status ?? 500 });
  }
}

5. Server-rendered page plus client refresh

The page renders the first view on the server; the client component re-fetches /api/scoreboard every 60 seconds and skips refreshes while the tab is hidden. Scores are hidden for games that have not started (the API reports 0 before kickoff).

nextjs-scoreboard/app/page.js

// Server component: fetches the scoreboard on the server (key stays here), then hands the data to
// the client component, which refreshes it every 60 s through /api/scoreboard.
import Link from 'next/link';
import Scoreboard from './Scoreboard';
import { getScoreboard, LEAGUES } from '../lib/rsa';

export default async function Page({ searchParams }) {
  const league = LEAGUES[searchParams?.league] ? searchParams.league : 'nfl';
  let data = null;
  let error = null;
  try {
    data = await getScoreboard(league);
  } catch (e) {
    error = e.message;
  }

  return (
    <main>
      <h1>{LEAGUES[league].name} scoreboard</h1>
      <nav>
        {Object.entries(LEAGUES).map(([slug, cfg]) => (
          <Link key={slug} href={`/?league=${slug}`} aria-current={slug === league ? 'page' : undefined}>
            {cfg.name}
          </Link>
        ))}
      </nav>
      {error ? <p className="error">{error}</p> : <Scoreboard key={league} initial={data} />}
      <p className="meta" style={{ marginTop: 32 }}>
        Scores from the{' '}
        <a href="https://www.realtimesportsapi.com/?utm_source=github&utm_medium=nextjs-scoreboard">Realtime Sports API</a>.
      </p>
    </main>
  );
}

nextjs-scoreboard/app/Scoreboard.js

'use client';
// Renders the games and refreshes them from our own /api/scoreboard route every 60 s.
// The browser never sees the API key; it only talks to this app.
import { useEffect, useState } from 'react';

const REFRESH_MS = 60_000;

function Game({ g }) {
  const showScore = g.state !== 'pre';
  return (
    <div className="game">
      <div>
        {[g.away, g.home].map((t, i) => (
          <div key={i} className={`team${g.state === 'post' && t.winner ? ' winner' : ''}`}>
            <span title={t.name}>{t.abbr ?? t.name}</span>
            <span className="score">{showScore ? t.score ?? 0 : ''}</span>
          </div>
        ))}
      </div>
      <div className={`status${g.state === 'in' ? ' live' : ''}`}>
        {g.state === 'in' ? 'LIVE · ' : ''}
        {g.detail}
        {g.venue && g.state === 'pre' ? ` · ${g.venue}` : ''}
      </div>
    </div>
  );
}

export default function Scoreboard({ initial }) {
  const [data, setData] = useState(initial);
  const [error, setError] = useState(null);

  useEffect(() => {
    let stopped = false;
    async function refresh() {
      if (document.hidden) return; // don't poll from background tabs
      try {
        const res = await fetch(`/api/scoreboard?league=${encodeURIComponent(initial.league)}`);
        const body = await res.json();
        if (!res.ok) throw new Error(body.error || `HTTP ${res.status}`);
        if (!stopped) {
          setData(body);
          setError(null);
        }
      } catch (e) {
        if (!stopped) setError(e.message);
      }
    }
    const id = setInterval(refresh, REFRESH_MS);
    return () => {
      stopped = true;
      clearInterval(id);
    };
  }, [initial.league]);

  const groups = [
    ['Live', data.games.filter((g) => g.state === 'in')],
    ['Upcoming', data.games.filter((g) => g.state === 'pre')],
    ['Final', data.games.filter((g) => g.state === 'post').reverse()]
  ];

  return (
    <>
      <p className="meta">
        Updated {new Date(data.fetchedAt).toISOString().slice(11, 19)} UTC · refreshes every 60 s · data is typically 20-30 s
        behind live play
      </p>
      {error && <p className="error">Refresh failed: {error}</p>}
      {data.games.length === 0 && <p>No games on the scoreboard right now.</p>}
      {groups.map(([title, games]) =>
        games.length ? (
          <section key={title}>
            <h2>
              {title} ({games.length})
            </h2>
            {games.map((g) => (
              <Game key={g.id} g={g} />
            ))}
          </section>
        ) : null
      )}
    </>
  );
}

6. Run and deploy

Put the key in .env.local as REALTIME_SPORTS_API_KEY (no NEXT_PUBLIC_ prefix, so it stays on the server). On Vercel, set the same variable in the project settings and deploy.

Terminal

npm install
cp .env.example .env.local
npm run dev                  # http://localhost:3000/?league=nhl
npm run build && npm start   # production

7. Quota

With the 30-second cache, one league shown around the clock is at most about 2 calls a minute, roughly 86,000 a month, so cache longer or show the board only during game windows on smaller plans. If you just want a scoreboard on a page without code, the free widget needs no key.

Endpoints used

  • GET /sports/{sport}/leagues/{league}/events

Full reference: docs · OpenAPI · what each endpoint returns, by league

FAQ

Why not call the API from the browser?
The key would be visible to every visitor. The route handler keeps it on the server and lets the cache share one API call between all visitors.
Can I use WebSocket instead of polling?
Yes on paid plans, from a server process: exchange the key for a token at /api/websocket/auth and push updates to browsers yourself (for example with server-sent events). The guide keeps to polling because it works on every plan.

More guides