Build an Odds Comparison Dashboard
Create a React/Next.js dashboard that compares odds across multiple sportsbooks and highlights the best lines.
What You’ll Build
A web dashboard that:
- Displays live odds from all bookmakers side-by-side
- Highlights best odds for each market
- Auto-refreshes every 30 seconds
- Shows line movement indicators
- Responsive design for mobile/desktop
Perfect for: Odds comparison sites, betting platforms, research tools
Prerequisites
- Node.js 18+
- Basic React/Next.js knowledge
- SportsGameOdds API key (Get one free)
Complete Code
Step 1: Create Next.js Project
npx create-next-app@latest odds-dashboard
cd odds-dashboard
npm installWhen prompted, choose:
- TypeScript: Yes
- ESLint: Yes
- Tailwind CSS: Yes
- App Router: Yes
Step 2: Create API Route
Create app/api/odds/route.ts:
// app/api/odds/route.ts
import { NextResponse } from "next/server";
const API_KEY = process.env.SPORTSGAMEODDS_KEY!;
const API_BASE = "https://api.sportsgameodds.com/v2";
export async function GET(request: Request) {
const { searchParams } = new URL(request.url);
const league = searchParams.get("league") || "NBA";
try {
const response = await fetch(`${API_BASE}/events?leagueID=${league}&finalized=false&oddsAvailable=true&limit=20`, {
headers: { "x-api-key": API_KEY },
// Cache for 30 seconds
next: { revalidate: 30 },
});
if (!response.ok) {
throw new Error(`HTTP ${response.status}`);
}
const data = await response.json();
return NextResponse.json(data);
} catch (error) {
console.error("Error fetching odds:", error);
return NextResponse.json({ error: "Failed to fetch odds" }, { status: 500 });
}
}Step 3: Create Dashboard Component
Create components/OddsDashboard.tsx:
// components/OddsDashboard.tsx
'use client';
import { useState, useEffect } from 'react';
interface BookmakerOdds {
odds: string;
spread?: string;
overUnder?: string;
available: boolean;
lastUpdatedAt: string;
}
interface Odd {
oddID: string;
betTypeID: string;
sideID: string;
periodID: string;
byBookmaker: Record<string, BookmakerOdds>;
}
interface Event {
eventID: string;
teams: {
home: { names: { long: string; medium: string; short: string } };
away: { names: { long: string; medium: string; short: string } };
};
status: {
startsAt: string;
};
odds: Record<string, Odd>;
}
interface GroupedOdds {
[bookmaker: string]: {
home?: BookmakerOdds & { sideID: string };
away?: BookmakerOdds & { sideID: string };
};
}
export default function OddsDashboard() {
const [events, setEvents] = useState<Event[]>([]);
const [league, setLeague] = useState('NBA');
const [loading, setLoading] = useState(true);
const [lastUpdate, setLastUpdate] = useState<Date | null>(null);
const fetchOdds = async () => {
try {
const response = await fetch(`/api/odds?league=${league}`);
const data = await response.json();
if (data.data) {
setEvents(data.data);
setLastUpdate(new Date());
}
} catch (error) {
console.error('Error:', error);
} finally {
setLoading(false);
}
};
useEffect(() => {
fetchOdds();
// Auto-refresh every 30 seconds
const interval = setInterval(fetchOdds, 30000);
return () => clearInterval(interval);
}, [league]);
const groupOddsByMarket = (odds: Record<string, Odd>, betType: string): GroupedOdds => {
const grouped: GroupedOdds = {};
Object.values(odds).forEach(odd => {
// Only process full game odds for the specified bet type
if (odd.betTypeID !== betType || odd.periodID !== 'game') return;
// Iterate through each bookmaker's odds
Object.entries(odd.byBookmaker || {}).forEach(([bookmakerID, bookmakerOdds]) => {
if (!bookmakerOdds.available) return;
if (!grouped[bookmakerID]) {
grouped[bookmakerID] = {};
}
grouped[bookmakerID][odd.sideID as 'home' | 'away'] = {
...bookmakerOdds,
sideID: odd.sideID
};
});
});
return grouped;
};
const findBestOdds = (odds: Record<string, Odd>, betType: string, side: 'home' | 'away') => {
let bestBookmaker: string | null = null;
let bestOddsValue = -Infinity;
Object.values(odds).forEach(odd => {
if (odd.betTypeID !== betType || odd.sideID !== side || odd.periodID !== 'game') return;
Object.entries(odd.byBookmaker || {}).forEach(([bookmakerID, bookmakerOdds]) => {
if (!bookmakerOdds.available) return;
const oddsNum = parseInt(bookmakerOdds.odds);
if (!isNaN(oddsNum) && oddsNum > bestOddsValue) {
bestOddsValue = oddsNum;
bestBookmaker = bookmakerID;
}
});
});
return bestBookmaker;
};
const formatOdds = (price: string | number) => {
const num = typeof price === 'string' ? parseInt(price) : price;
if (isNaN(num)) return 'N/A';
return num > 0 ? `+${num}` : num.toString();
};
const formatSpread = (spread: string | undefined) => {
if (!spread) return '';
// API already includes + or - sign
return spread;
};
if (loading) {
return (
<div className="flex justify-center items-center h-64">
<div className="text-xl">Loading odds...</div>
</div>
);
}
return (
<div className="container mx-auto px-4 py-8">
{/* Header */}
<div className="mb-8">
<h1 className="text-4xl font-bold mb-4">Odds Comparison</h1>
{/* League Selector */}
<div className="flex gap-4 mb-4">
{['NBA', 'NFL', 'NHL', 'MLB'].map(l => (
<button
key={l}
onClick={() => setLeague(l)}
className={`px-4 py-2 rounded-lg font-semibold transition ${
league === l
? 'bg-blue-600 text-white'
: 'bg-gray-200 hover:bg-gray-300'
}`}
>
{l}
</button>
))}
</div>
{/* Last Update */}
{lastUpdate && (
<div className="text-sm text-gray-600">
Last updated: {lastUpdate.toLocaleTimeString()}
</div>
)}
</div>
{/* Games */}
<div className="space-y-8">
{events.map(event => {
const spreadOdds = groupOddsByMarket(event.odds, 'sp');
const bestHomeSpread = findBestOdds(event.odds, 'sp', 'home');
const bestAwaySpread = findBestOdds(event.odds, 'sp', 'away');
return (
<div key={event.eventID} className="bg-white rounded-lg shadow-lg p-6">
{/* Matchup */}
<div className="mb-4">
<div className="text-2xl font-bold">
{event.teams.away.names.long} @ {event.teams.home.names.long}
</div>
<div className="text-sm text-gray-600">
{event.status?.startsAt
? new Date(event.status.startsAt).toLocaleString()
: 'TBD'}
</div>
</div>
{/* Spread Odds Table */}
<div className="overflow-x-auto">
<table className="w-full">
<thead>
<tr className="border-b">
<th className="text-left py-2">Bookmaker</th>
<th className="text-center py-2">{event.teams.away.names.medium}</th>
<th className="text-center py-2">{event.teams.home.names.medium}</th>
</tr>
</thead>
<tbody>
{Object.entries(spreadOdds).map(([bookmaker, odds]) => (
<tr key={bookmaker} className="border-b hover:bg-gray-50">
<td className="py-3 font-medium capitalize">{bookmaker}</td>
{/* Away Spread */}
<td className="py-3 text-center">
{odds.away ? (
<div
className={`inline-block px-3 py-1 rounded ${
bestAwaySpread === bookmaker
? 'bg-green-100 border-2 border-green-500 font-bold'
: 'bg-gray-100'
}`}
>
{formatSpread(odds.away.spread)} {formatOdds(odds.away.odds)}
</div>
) : (
<span className="text-gray-400">-</span>
)}
</td>
{/* Home Spread */}
<td className="py-3 text-center">
{odds.home ? (
<div
className={`inline-block px-3 py-1 rounded ${
bestHomeSpread === bookmaker
? 'bg-green-100 border-2 border-green-500 font-bold'
: 'bg-gray-100'
}`}
>
{formatSpread(odds.home.spread)} {formatOdds(odds.home.odds)}
</div>
) : (
<span className="text-gray-400">-</span>
)}
</td>
</tr>
))}
</tbody>
</table>
</div>
{/* Best Odds Summary */}
<div className="mt-4 p-4 bg-green-50 rounded-lg">
<div className="font-semibold mb-2">Best Odds:</div>
<div className="grid grid-cols-2 gap-4">
{bestAwaySpread && spreadOdds[bestAwaySpread]?.away && (
<div>
<span className="font-medium">{event.teams.away.names.medium}:</span>{' '}
{formatSpread(spreadOdds[bestAwaySpread].away?.spread)}{' '}
{formatOdds(spreadOdds[bestAwaySpread].away?.odds || '')}{' '}
({bestAwaySpread})
</div>
)}
{bestHomeSpread && spreadOdds[bestHomeSpread]?.home && (
<div>
<span className="font-medium">{event.teams.home.names.medium}:</span>{' '}
{formatSpread(spreadOdds[bestHomeSpread].home?.spread)}{' '}
{formatOdds(spreadOdds[bestHomeSpread].home?.odds || '')}{' '}
({bestHomeSpread})
</div>
)}
</div>
</div>
</div>
);
})}
</div>
{events.length === 0 && (
<div className="text-center text-gray-600 py-12">
No upcoming {league} games found
</div>
)}
</div>
);
}Step 4: Update Main Page
Update app/page.tsx:
// app/page.tsx
import OddsDashboard from '@/components/OddsDashboard';
export default function Home() {
return <OddsDashboard />;
}Step 5: Add Environment Variable
Create .env.local:
SPORTSGAMEODDS_KEY=your_api_key_hereStep 6: Run It
npm run devOpen http://localhost:3000 in your browser
Expected Output
Features you’ll see:
- All bookmakers side-by-side
- Best odds highlighted in green
- League switcher (NBA/NFL/NHL/MLB)
- Auto-refresh every 30 seconds
- Start time for each game
- Best odds summary at bottom
How It Works
1. Proxy API Calls Through Backend
// app/api/odds/route.ts
const response = await fetch(`${API_BASE}/events?leagueID=${league}&finalized=false`, {
headers: { "x-api-key": API_KEY },
next: { revalidate: 30 }, // Cache for 30 seconds
});Why backend? Keeps API key secure (never exposed in browser).
2. Process Nested Bookmaker Structure
The API returns odds with a nested byBookmaker structure:
{
"odds": {
"points-home-game-sp-home": {
"oddID": "points-home-game-sp-home",
"betTypeID": "sp",
"sideID": "home",
"periodID": "game",
"byBookmaker": {
"draftkings": {
"odds": "-110",
"spread": "-5.5",
"available": true
},
"fanduel": {
"odds": "-108",
"spread": "-5.5",
"available": true
}
}
}
}
}We transform this into a grouped format by bookmaker:
const groupOddsByMarket = (odds, betType) => {
const grouped = {};
Object.values(odds).forEach((odd) => {
if (odd.betTypeID !== betType || odd.periodID !== "game") return;
Object.entries(odd.byBookmaker || {}).forEach(([bookmakerID, bookmakerOdds]) => {
if (!grouped[bookmakerID]) {
grouped[bookmakerID] = {};
}
grouped[bookmakerID][odd.sideID] = bookmakerOdds;
});
});
return grouped;
};3. Find Best Odds
// Find best odds across all bookmakers for a specific market
let bestBookmaker = null;
let bestOddsValue = -Infinity;
Object.entries(odd.byBookmaker || {}).forEach(([bookmakerID, bookmakerOdds]) => {
const oddsNum = parseInt(bookmakerOdds.odds);
// Higher (less negative or more positive) odds = better value
if (oddsNum > bestOddsValue) {
bestOddsValue = oddsNum;
bestBookmaker = bookmakerID;
}
});Higher odds value = better odds for bettors (e.g., +150 is better than +120, -110 is better than -120).
4. Highlight Best Odds
className={
bestAwaySpread === bookmaker
? 'bg-green-100 border-2 border-green-500 font-bold'
: 'bg-gray-100'
}Green highlight shows best available odds.
5. Auto-Refresh
useEffect(() => {
fetchOdds();
const interval = setInterval(fetchOdds, 30000); // Every 30s
return () => clearInterval(interval);
}, [league]);Enhancements
Add Moneyline and Totals
// In OddsDashboard component
const [selectedMarket, setSelectedMarket] = useState('sp');
// Market selector
<select onChange={(e) => setSelectedMarket(e.target.value)}>
<option value="sp">Spread</option>
<option value="ml">Moneyline</option>
<option value="ou">Total (Over/Under)</option>
</select>
// Use selectedMarket in groupOddsByMarket
const odds = groupOddsByMarket(event.odds, selectedMarket);Show Line Movement
const [previousOdds, setPreviousOdds] = useState<Record<string, number>>({});
useEffect(() => {
const current: Record<string, number> = {};
events.forEach((event) => {
Object.entries(event.odds).forEach(([oddID, odd]) => {
Object.entries(odd.byBookmaker || {}).forEach(([bookmakerID, bookmakerOdds]) => {
const key = `${oddID}-${bookmakerID}`;
current[key] = parseInt(bookmakerOdds.odds);
});
});
});
setPreviousOdds(current);
}, [events]);
// In render
const getMovement = (oddID: string, bookmakerID: string, currentOdds: string) => {
const key = `${oddID}-${bookmakerID}`;
const previous = previousOdds[key];
const current = parseInt(currentOdds);
if (!previous) return null;
if (current > previous) return "up"; // Improved for bettor
if (current < previous) return "down"; // Worsened for bettor
return null;
};Add Filters
const [filters, setFilters] = useState({
minOdds: -200,
maxOdds: 200,
bookmakers: [] as string[],
});
const filteredOdds = Object.entries(spreadOdds).filter(([bookmaker, odds]) => {
// Filter by bookmaker
if (filters.bookmakers.length > 0 && !filters.bookmakers.includes(bookmaker)) {
return false;
}
// Filter by odds range
const homeOdds = odds.home?.odds ? parseInt(odds.home.odds) : 0;
const awayOdds = odds.away?.odds ? parseInt(odds.away.odds) : 0;
return homeOdds >= filters.minOdds && homeOdds <= filters.maxOdds && awayOdds >= filters.minOdds && awayOdds <= filters.maxOdds;
});Export to CSV
const exportToCSV = () => {
const rows: any[] = [];
events.forEach((event) => {
Object.entries(event.odds).forEach(([oddID, odd]) => {
Object.entries(odd.byBookmaker || {}).forEach(([bookmaker, bOdds]) => {
rows.push({
game: `${event.teams.away.names.long} @ ${event.teams.home.names.long}`,
bookmaker,
market: odd.betTypeID,
side: odd.sideID,
line: bOdds.spread || bOdds.overUnder,
price: bOdds.odds,
});
});
});
});
const csv = [Object.keys(rows[0]).join(","), ...rows.map((row) => Object.values(row).join(","))].join("\n");
const blob = new Blob([csv], { type: "text/csv" });
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = `odds-${Date.now()}.csv`;
a.click();
};Deployment
Deploy to Vercel
npm install -g vercel
vercelAdd environment variable in Vercel dashboard:
SPORTSGAMEODDS_KEY=your_api_key
Deploy to Netlify
npm run build
netlify deploy --prod --dir=.nextAdd environment variable in Netlify settings.
Troubleshooting
“Failed to fetch odds” Error
Cause: Missing or invalid API key.
Solution: Check .env.local:
# Verify file exists
cat .env.local
# Should output: SPORTSGAMEODDS_KEY=your_key_here
# Restart dev server after adding
npm run devOdds Not Updating
Cause: Browser caching.
Solution: Add cache-busting:
const response = await fetch(
`/api/odds?league=${league}&t=${Date.now()}`, // Add timestamp
{ cache: "no-store" } // Disable caching
);Best Odds Not Highlighted
Cause: Type mismatch (string vs number).
Solution: Ensure odds values are parsed as numbers:
const oddsNum = parseInt(bookmakerOdds.odds);
if (isNaN(oddsNum)) return; // Skip invalid oddsNext Steps
Combine with Other Examples
- Live Odds Tracker - Add real-time movement alerts
- Arbitrage Calculator - Highlight arb opportunities in UI
Learn More
- Understanding oddID - Decode odds structure
- SDK Guide - Use TypeScript SDK for type safety
- Best Practices - Optimize performance
