Most betting dashboard tutorials stop at a table of static odds. Real dashboards are different: prices move every few seconds, the connection drops at the worst moment, and you have to decide which parts of your stack are allowed to touch your API key.
In this tutorial we will build a live odds dashboard with Next.js that:
- Shows odds for multiple sports on tabs
- Updates in real time over a WebSocket
- Highlights price movement (up or down) as it happens
- Works without an API key in mock mode, so you can build the UI first
- Keeps your API key on the server, never in the browser
What this is not: this dashboard displays and analyzes odds. It does not place bets or give betting advice. Gambling laws and minimum ages differ by country, so check the rules where you and your users are.
Disclosure: I use Orbistats as the data source. The architecture works with any provider that offers REST plus WebSocket.
What we are building
Provider WebSocket --> relay (Node) --SSE--> Next.js UI (browser)
Provider REST --> Next.js route handler (cached snapshot) --> UI
There are two data paths. The REST snapshot is what the page shows on first load. The live stream is what keeps it updating.
Why not connect the browser straight to the WebSocket?
There are two reasons. First, your API key would ship to every visitor, because anything in a client bundle is public. Second, Next.js on serverless cannot hold a long-lived WebSocket, because functions spin down.
So a small relay process holds one upstream connection and fans updates out to browsers over Server-Sent Events (SSE). One upstream connection also protects your plan limits, no matter how many people open the dashboard.
Step 1: Pick your data source and test it
Before writing code, look at real JSON. The public API Sandbox lets you run sample requests without a key. Then:
- The Odds API page describes pre-match and live/in-play odds, normalized across bookmakers and markets so you consume one consistent shape.
- The Sports Data API covers fixtures, results, standings, teams and players. Use it for match names and schedules.
- The Live Scores API gives match state, score and status.
- The Sports Statistics API is handy if you later add team form panels.
For a real key, see the pricing page for current free-tier limits (plans change, so always check). The documentation and the developer hub have the API reference.
Honest expectations:
- Free-tier live data can be delayed compared with paid plans.
- The sub-50ms latency on the Orbistats site is the vendor's own marketing claim, not an independent benchmark. Measure delivery lag yourself if it matters.
- Market depth (player props, futures, line history) can vary by competition.
Step 2: Create the project
npx create-next-app@latest odds-dashboard --ts --app --tailwind --eslint
cd odds-dashboard
mkdir relay lib
npm i ws dotenv
npm i -D tsx vitest @types/ws
Create a file named .env for the relay:
MOCK=1
ORBISTATS_API_KEY=your_key_here
ORBISTATS_WS_URL=wss://stream.orbistats.com/...
RELAY_PORT=4001
Set MOCK=1 for fake data and MOCK=0 for the real feed. Copy the exact WebSocket URL from the docs.
Create a file named .env.local for Next.js:
ORBISTATS_API_KEY=your_key_here
ORBISTATS_REST_BASE=https://api.orbistats.com/v1
NEXT_PUBLIC_RELAY_URL=http://localhost:4001
Add both files to .gitignore. Only NEXT_PUBLIC_RELAY_URL is allowed to reach the browser.
Step 3: Types and the single adaptation point
This is the most important file. Every provider quirk lives here, so when a field name differs from my guess, you edit one function.
// lib/types.ts
export const SPORTS = [
'football', 'basketball', 'american-football', 'cricket', 'tennis',
'baseball', 'esports', 'combat-sports', 'volleyball', 'handball',
'ice-hockey', 'golf', 'horse-racing',
] as const;
export type Sport = (typeof SPORTS)[number];
export interface OddsUpdate {
matchId: string;
sport: Sport;
league?: string;
home: string;
away: string;
status: string;
minute?: number | null;
score?: { home: number; away: number };
odds: { home: number; draw?: number; away: number };
ts: number;
}
// lib/normalize.ts
import { OddsUpdate, SPORTS, Sport } from './types';
// PLACEHOLDER mapping: compare a real message from the sandbox or
// WebSocket docs and edit ONLY this function.
export function normalize(raw: any): OddsUpdate | null {
if (!raw || typeof raw !== 'object') return null;
const id = raw.fixture_id ?? raw.match_id ?? raw.id;
const home = raw.home?.name ?? raw.home_team;
const away = raw.away?.name ?? raw.away_team;
const sport = raw.sport as Sport;
const h = Number(raw.odds?.home ?? raw.odds?.['1']);
const a = Number(raw.odds?.away ?? raw.odds?.['2']);
const d = raw.odds?.draw ?? raw.odds?.X;
if (!id || !home || !away || !SPORTS.includes(sport)) return null;
if (!Number.isFinite(h) || !Number.isFinite(a)) return null;
return {
matchId: String(id),
sport,
league: raw.competition?.name ?? raw.league,
home,
away,
status: raw.status ?? 'scheduled',
minute: raw.minute ?? null,
score: raw.score
? { home: Number(raw.score.home ?? 0), away: Number(raw.score.away ?? 0) }
: undefined,
odds: { home: h, away: a, ...(d != null ? { draw: Number(d) } : {}) },
ts: Date.now(),
};
}
Returning null on anything suspicious matters. A bad frame should be dropped, not crash your UI.
Step 4: The relay
The relay does four jobs: hold the upstream socket, reconnect with backoff, keep the latest state per match, and broadcast over SSE. It also has a mock mode.
// relay/server.ts
import 'dotenv/config';
import http, { ServerResponse } from 'node:http';
import WebSocket from 'ws';
import { normalize } from '../lib/normalize';
import { OddsUpdate } from '../lib/types';
const { MOCK = '1', ORBISTATS_API_KEY, ORBISTATS_WS_URL, RELAY_PORT = '4001' } = process.env;
const clients = new Set<ServerResponse>();
const latest = new Map<string, OddsUpdate>();
function broadcast(u: OddsUpdate) {
latest.set(u.matchId, u);
const frame = `data: ${JSON.stringify(u)}\n\n`;
for (const res of clients) res.write(frame);
}
// SSE server
http.createServer((req, res) => {
if (req.url?.startsWith('/stream')) {
res.writeHead(200, {
'Content-Type': 'text/event-stream',
'Cache-Control': 'no-cache',
Connection: 'keep-alive',
'Access-Control-Allow-Origin': 'http://localhost:3000',
});
// send current state immediately so new tabs are not empty
for (const u of latest.values()) res.write(`data: ${JSON.stringify(u)}\n\n`);
clients.add(res);
req.on('close', () => clients.delete(res));
return;
}
res.writeHead(404).end();
}).listen(Number(RELAY_PORT), () => console.log(`relay on :${RELAY_PORT} (MOCK=${MOCK})`));
// keep proxies from closing idle SSE connections
setInterval(() => clients.forEach(r => r.write(': ping\n\n')), 15000);
// Real feed
let attempt = 0;
function connectUpstream() {
const ws = new WebSocket(ORBISTATS_WS_URL!, {
headers: { Authorization: `Bearer ${ORBISTATS_API_KEY}` }, // confirm auth method in docs
});
let alive = true;
let hb: NodeJS.Timeout;
ws.on('open', () => {
console.log('upstream connected');
attempt = 0;
// PLACEHOLDER: copy the real subscribe payload from the WebSocket docs
ws.send(JSON.stringify({ action: 'subscribe', channel: 'odds' }));
hb = setInterval(() => {
if (!alive) return ws.terminate();
alive = false;
ws.ping();
}, 30000);
});
ws.on('pong', () => (alive = true));
ws.on('message', buf => {
try {
const msg = JSON.parse(buf.toString());
const items = Array.isArray(msg.data) ? msg.data : [msg.data ?? msg];
for (const raw of items) {
const u = normalize(raw);
if (u) broadcast(u);
}
} catch {
// ignore malformed frames
}
});
ws.on('error', e => console.error('upstream error:', e.message));
ws.on('close', () => {
clearInterval(hb);
const delay = Math.min(30000, 1000 * Math.pow(2, attempt++)) + Math.random() * 500;
console.log(`upstream closed, retry in ${Math.round(delay)}ms`);
setTimeout(connectUpstream, delay);
});
}
// Mock feed (no key needed)
function startMock() {
const teams = [
['football', 'Northport FC', 'Riverside United'],
['football', 'Alta City', 'Kestrel Athletic'],
['basketball', 'Harbor Hawks', 'Summit Wolves'],
['cricket', 'Coastal Kings', 'Inland Titans'],
['tennis', 'A. Novak', 'L. Reyes'],
['ice-hockey', 'Frost Giants', 'Iron Bears'],
] as const;
const state = teams.map(([sport, home, away], i) => ({
matchId: `mock-${i}`, sport, home, away,
h: 1.6 + Math.random(), a: 2.2 + Math.random(),
}));
setInterval(() => {
for (const s of state) {
s.h = Math.max(1.05, +(s.h + (Math.random() - 0.5) * 0.12).toFixed(2));
s.a = Math.max(1.05, +(s.a + (Math.random() - 0.5) * 0.12).toFixed(2));
const u = normalize({
id: s.matchId, sport: s.sport, home_team: s.home, away_team: s.away,
status: 'live', odds: { home: s.h, away: s.a },
});
if (u) broadcast(u);
}
}, 2000);
}
if (MOCK === '1') startMock();
else connectUpstream();
Run it with: npx tsx relay/server.ts
Why a random walk in mock mode? It exercises the exact code path the real feed will: frequent small changes, in both directions.
Step 5: REST snapshot with caching
The snapshot gives the page content before the stream connects. The route handler keeps your key on the server and caches responses, so 100 visitors cost you 1 upstream request.
// app/api/snapshot/route.ts
import { NextRequest, NextResponse } from 'next/server';
import { normalize } from '@/lib/normalize';
import { SPORTS } from '@/lib/types';
export const revalidate = 15;
export async function GET(req: NextRequest) {
const sport = req.nextUrl.searchParams.get('sport') ?? 'football';
if (!SPORTS.includes(sport as any)) {
return NextResponse.json({ error: 'unknown sport' }, { status: 400 });
}
// PLACEHOLDER path: confirm the real odds endpoint in the docs
const url = `${process.env.ORBISTATS_REST_BASE}/${sport}/odds`;
const res = await fetch(url, {
headers: { Authorization: `Bearer ${process.env.ORBISTATS_API_KEY}` },
next: { revalidate: 15 },
});
if (!res.ok) {
return NextResponse.json({ error: 'upstream failed', status: res.status }, { status: 502 });
}
const json = await res.json();
const items = Array.isArray(json.data) ? json.data : [];
return NextResponse.json(items.map(normalize).filter(Boolean));
}
Validating the sport parameter against the allow-list stops anyone from using your route to hit arbitrary upstream paths.
Step 6: Price movement logic
Keep this pure so it is trivial to test.
// lib/movement.ts
export type Dir = 'up' | 'down' | 'same';
export function direction(prev: number | undefined, next: number): Dir {
if (prev === undefined || prev === next) return 'same';
return next > prev ? 'up' : 'down';
}
One thing to remember: with decimal odds, a number going up means the outcome became less likely. Some UIs colour it red for that reason. Pick a convention and label it.
Step 7: The live hook
// app/useLiveOdds.ts
'use client';
import { useEffect, useRef, useState } from 'react';
import { OddsUpdate, Sport } from '@/lib/types';
import { direction, Dir } from '@/lib/movement';
export interface Row extends OddsUpdate {
moves: { home: Dir; draw: Dir; away: Dir };
}
export function useLiveOdds(sport: Sport) {
const [rows, setRows] = useState<Record<string, Row>>({});
const [connected, setConnected] = useState(false);
const prev = useRef<Record<string, OddsUpdate>>({});
// 1) initial snapshot
useEffect(() => {
let cancelled = false;
fetch(`/api/snapshot?sport=${sport}`)
.then(r => (r.ok ? r.json() : []))
.then((items: OddsUpdate[]) => {
if (cancelled) return;
const next: Record<string, Row> = {};
for (const u of items) {
next[u.matchId] = { ...u, moves: { home: 'same', draw: 'same', away: 'same' } };
prev.current[u.matchId] = u;
}
setRows(next);
})
.catch(() => {});
return () => { cancelled = true; };
}, [sport]);
// 2) live stream
useEffect(() => {
const es = new EventSource(`${process.env.NEXT_PUBLIC_RELAY_URL}/stream`);
es.onopen = () => setConnected(true);
es.onerror = () => setConnected(false);
es.onmessage = e => {
const u: OddsUpdate = JSON.parse(e.data);
if (u.sport !== sport) return;
const p = prev.current[u.matchId];
const moves = {
home: direction(p?.odds.home, u.odds.home),
draw: direction(p?.odds.draw, u.odds.draw ?? 0),
away: direction(p?.odds.away, u.odds.away),
};
prev.current[u.matchId] = u;
setRows(r => ({ ...r, [u.matchId]: { ...u, moves } }));
};
return () => es.close();
}, [sport]);
return { rows: Object.values(rows), connected };
}
EventSource reconnects automatically, which is one reason SSE is a good fit for a one-way price feed.
Step 8: The UI
// app/page.tsx
'use client';
import { useState } from 'react';
import { SPORTS, Sport } from '@/lib/types';
import { useLiveOdds } from './useLiveOdds';
const arrow = { up: '▲', down: '▼', same: '' } as const;
const color = { up: 'text-red-500', down: 'text-green-500', same: 'text-slate-200' } as const;
export default function Dashboard() {
const [sport, setSport] = useState<Sport>('football');
const { rows, connected } = useLiveOdds(sport);
return (
<main className="mx-auto max-w-5xl p-6 text-slate-100 bg-slate-950 min-h-screen">
<header className="flex items-center justify-between mb-4">
<h1 className="text-2xl font-semibold">Live Odds</h1>
<span className={connected ? 'text-green-400' : 'text-amber-400'}>
{connected ? '● live' : '● reconnecting…'}
</span>
</header>
<nav className="flex gap-2 overflow-x-auto pb-3">
{SPORTS.map(s => (
<button
key={s}
onClick={() => setSport(s)}
className={`px-3 py-1 rounded-full text-sm whitespace-nowrap ${
s === sport ? 'bg-blue-600' : 'bg-slate-800 hover:bg-slate-700'
}`}
>
{s.replace('-', ' ')}
</button>
))}
</nav>
<section className="grid gap-3 sm:grid-cols-2 mt-3">
{rows.length === 0 && <p className="text-slate-400">No matches for this sport right now.</p>}
{rows.map(r => (
<article key={r.matchId} className="rounded-xl bg-slate-900 p-4 border border-slate-800">
<p className="text-xs text-slate-400">{r.league ?? r.sport} · {r.status}</p>
<p className="font-medium mt-1">{r.home} vs {r.away}</p>
<div className="flex gap-6 mt-3 font-mono">
<span className={color[r.moves.home]}>{r.odds.home.toFixed(2)} {arrow[r.moves.home]}</span>
{r.odds.draw && <span className={color[r.moves.draw]}>{r.odds.draw.toFixed(2)} {arrow[r.moves.draw]}</span>}
<span className={color[r.moves.away]}>{r.odds.away.toFixed(2)} {arrow[r.moves.away]}</span>
</div>
</article>
))}
</section>
<p className="text-xs text-slate-500 mt-6">
Odds are informational only. 18+ (or your local minimum age). Data: Orbistats.
</p>
</main>
);
}
Run both processes in two terminals:
npx tsx relay/server.ts
npm run dev
Open localhost:3000 and you should see numbers ticking with arrows within two seconds. If you kill the relay, the badge flips to reconnecting and recovers when you restart it.
Step 9: Tests
// lib/core.test.ts
import { describe, it, expect } from 'vitest';
import { normalize } from './normalize';
import { direction } from './movement';
describe('normalize', () => {
it('rejects junk', () => {
expect(normalize(null)).toBeNull();
expect(normalize({ id: 1 })).toBeNull();
expect(normalize({ id: 1, sport: 'football', home_team: 'A', away_team: 'B', odds: { home: 'x', away: 2 } })).toBeNull();
});
it('maps a valid payload', () => {
const u = normalize({ id: 7, sport: 'tennis', home_team: 'A', away_team: 'B', odds: { home: 1.5, away: 2.6 } });
expect(u?.matchId).toBe('7');
expect(u?.odds.home).toBe(1.5);
});
it('rejects unknown sports', () => {
expect(normalize({ id: 1, sport: 'quidditch', home_team: 'A', away_team: 'B', odds: { home: 1.5, away: 2 } })).toBeNull();
});
});
describe('direction', () => {
it('detects moves', () => {
expect(direction(1.8, 1.9)).toBe('up');
expect(direction(1.8, 1.7)).toBe('down');
expect(direction(1.8, 1.8)).toBe('same');
expect(direction(undefined, 1.8)).toBe('same');
});
});
Run the tests with: npx vitest run
Going from mock to real: checklist
- Get a key (see the pricing page) and open the WebSocket API docs and the sandbox.
- Copy a real message and edit only normalize().
- Copy the real subscribe payload and confirm how the key is sent (header, query string, or first message).
- Set MOCK=0 in .env, and confirm the REST odds path and response shape in the snapshot route.
- Read the changelog before shipping, since APIs change.
Other sports work the same way (mostly)
Orbistats now lists 13 sports on its homepage, including tennis, cricket, esports, golf and horse racing. The provider describes one API shape across sports, but odds semantics differ. Tennis has two outcomes and no draw, cricket can have draws in some formats, horse racing has many runners rather than home and away, and golf is mostly outright-winner markets. Extend OddsUpdate per sport instead of forcing everything into home, draw, away. Also verify each sport's slug in the docs, because I used the obvious names.
Common problems and fixes
| Symptom | Likely cause | Fix |
|---|---|---|
| Page loads, never updates | Relay not running or wrong NEXT_PUBLIC_RELAY_URL | Check the relay logs and the badge |
| Stream works locally, dies in production | Proxy buffering SSE | Disable buffering and keep the 15s ping |
| Everything shows same | First frame has no previous state | Expected on first load; watch the second update |
| 429 errors | Snapshot route uncached | Keep revalidate and cache per sport |
| Empty odds for some matches | Market not offered for that competition | Handle missing markets in normalize() |
Where to go next
- Odds history chart: store updates and plot line movement, then backfill with the Historical Sports Data API.
- Alerts: use the Webhooks API for server-side triggers such as a price moving 10 percent, and keep the WebSocket for the UI.
- Deploy: run the relay on a small always-on host (Fly.io, Railway, a VPS) and Next.js on Vercel.
- Provider abstraction: keep all provider code behind normalize() so you can swap sources later.
- Learn more: the About page explains who is behind the platform.
Wrap-up
The pattern that made this dashboard reliable: a server-side relay for the key and the socket, a cached REST snapshot for first paint, one normalize() function as the only provider-specific code, and a mock mode so you can build without waiting on credentials. Swap the placeholders for real payloads and you have a production-shaped foundation.
If you build something on top of this, share it in the comments. And if your payload looks different from my normalize() guess, tell me what you found and I will update the post.
Top comments (0)