Skip to content

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

bash
npx create-next-app@latest odds-dashboard
cd odds-dashboard
npm install

When prompted, choose:

  • TypeScript: Yes
  • ESLint: Yes
  • Tailwind CSS: Yes
  • App Router: Yes

Step 2: Create API Route

Create app/api/odds/route.ts:

typescript
// 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:

typescript
// 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:

typescript
// app/page.tsx
import OddsDashboard from '@/components/OddsDashboard';

export default function Home() {
  return <OddsDashboard />;
}

Step 5: Add Environment Variable

Create .env.local:

bash
SPORTSGAMEODDS_KEY=your_api_key_here

Step 6: Run It

bash
npm run dev

Open 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

typescript
// 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:

json
{
  "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:

typescript
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

typescript
// 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

typescript
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

typescript
useEffect(() => {
  fetchOdds();

  const interval = setInterval(fetchOdds, 30000); // Every 30s

  return () => clearInterval(interval);
}, [league]);

Enhancements

Add Moneyline and Totals

typescript
// 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

typescript
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

typescript
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

typescript
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

bash
npm install -g vercel
vercel

Add environment variable in Vercel dashboard:

  • SPORTSGAMEODDS_KEY=your_api_key

Deploy to Netlify

bash
npm run build
netlify deploy --prod --dir=.next

Add environment variable in Netlify settings.

Troubleshooting

“Failed to fetch odds” Error

Cause: Missing or invalid API key.

Solution: Check .env.local:

bash
# Verify file exists
cat .env.local

# Should output: SPORTSGAMEODDS_KEY=your_key_here

# Restart dev server after adding
npm run dev

Odds Not Updating

Cause: Browser caching.

Solution: Add cache-busting:

typescript
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:

typescript
const oddsNum = parseInt(bookmakerOdds.odds);
if (isNaN(oddsNum)) return; // Skip invalid odds

Next Steps

Combine with Other Examples

Learn More