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
- Code
- nextjs-scoreboard/ (MIT)
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-scoreboard3. 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 # production7. 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.