Krok Odds
Guide

Build an AFL Odds Comparison Dashboard in Next.js

Build a real-time AFL odds comparison dashboard in Next.js. Step-by-step tutorial with full code, fetching odds from AU bookmakers.

David Koval
David Koval
Senior Advantage Betting Contributor
14 min read·Published 22 Aug 2026

An AFL odds comparison dashboard is the perfect first project for anyone building with betting data. It is small enough to finish in an afternoon, and it forces you to solve the four problems that appear in every betting product: fetching, normalising, computing best price, and caching so you do not bankrupt yourself on API calls.

By the end of this tutorial you will have a Next.js App Router page that pulls AFL head-to-head prices across every Australian bookmaker, highlights the best available price per team, computes book percentage, and flags arbitrage — server-rendered, cached, and typed end to end.

What we are building

  • A server component that fetches AFL odds and caches for 60 seconds
  • A comparison table with one row per match and one column per bookmaker
  • Best-price highlighting per team
  • Book percentage and margin per match
  • An arbitrage badge when best prices sum below 100%
  • Client-side sorting without a second API call

Step 1: project setup

npx create-next-app@latest afl-odds --typescript --app --tailwind
cd afl-odds

Add your key to .env.local:

KROK_API_KEY=your_key_here

No NEXT_PUBLIC_ prefix. That prefix ships the value to the browser, and an API key in the browser is an API key on someone else's bill. Grab a free key from Krok Odds API access if you do not have one.

Step 2: types

Model the wire shape before you touch the network. It makes every subsequent step type-checked.

// lib/types.ts
export interface Price {
  bookmaker: string;
  price: number;
  captured_at: string;
}

export interface Selection {
  name: string;
  prices: Price[];
}

export interface Market {
  key: string;            // 'h2h' | 'spreads' | 'totals'
  selections: Selection[];
}

export interface OddsEvent {
  event_id: string;
  sport: string;
  commence_time: string;
  home_team: string;
  away_team: string;
  markets: Market[];
}

export interface ApiEnvelope<T> {
  success: boolean;
  data: T;
  meta: { count: number; tier: string; timestamp: string };
}

Step 3: the fetch layer

One module, one responsibility: talk to the API and hand back typed data. Caching lives here too.

// lib/odds.ts
import type { ApiEnvelope, OddsEvent } from './types';

const BASE = 'https://krokodds.com.au/api/v1';
const REVALIDATE_SECONDS = 60;

export async function fetchAflOdds(): Promise<OddsEvent[]> {
  const res = await fetch(`${BASE}/odds-feed/sports/afl?limit=50`, {
    headers: { 'X-API-Key': process.env.KROK_API_KEY ?? '' },
    next: { revalidate: REVALIDATE_SECONDS },
  });

  if (!res.ok) {
    // Fail loudly. An empty array here would look identical to "no games".
    throw new Error(`odds feed returned ${res.status}`);
  }

  const json = (await res.json()) as ApiEnvelope<OddsEvent[]>;
  return json.data ?? [];
}

That next: { revalidate: 60 } is the most important line in the file. It turns unlimited page views into at most one upstream request per minute, shared across every visitor and every server instance that hits the same cache entry.

Step 4: the odds maths

Keep the arithmetic in a pure module so it is trivially testable and reusable.

// lib/analysis.ts
import type { Market, Price } from './types';

export interface BestPrice {
  selection: string;
  price: number;
  bookmaker: string;
  capturedAt: string;
}

const STALE_MS = 15 * 60 * 1000;

function isFresh(p: Price): boolean {
  return Date.now() - new Date(p.captured_at).getTime() < STALE_MS;
}

export function bestPrices(market: Market): BestPrice[] {
  return market.selections.map((sel) => {
    const fresh = sel.prices.filter(isFresh);
    const pool = fresh.length > 0 ? fresh : sel.prices;
    const best = pool.reduce((a, b) => (b.price > a.price ? b : a));
    return {
      selection: sel.name,
      price: best.price,
      bookmaker: best.bookmaker,
      capturedAt: best.captured_at,
    };
  });
}

/** Sum of implied probabilities, as a percentage. */
export function bookPercentage(prices: number[]): number {
  return prices.reduce((sum, p) => sum + 1 / p, 0) * 100;
}

export function marginPct(prices: number[]): number {
  return Math.max(0, bookPercentage(prices) - 100);
}

export function arbProfitPct(prices: number[]): number | null {
  const pct = bookPercentage(prices);
  return pct < 100 ? (100 / pct - 1) * 100 : null;
}

The staleness filter matters more than it looks. A bookmaker whose scraper broke forty minutes ago will still return its last price, and that price will frequently be the "best" one — because the market moved and it did not. Filtering on capture time keeps ghost prices out of your best-price column.

Same-corporate-group suppression

If you are going to show an arbitrage badge, you have to handle this. Ladbrokes and Neds run on the same Entain feed; a price gap between them is not tradeable.

// lib/groups.ts
const GROUPS: Record<string, string> = {
  ladbrokes: 'entain', neds: 'entain', betstar: 'entain',
  sportsbet: 'flutter',
  betr: 'betr', betright: 'betr', boombet: 'betr',
  tab: 'tabgroup', unibet: 'tabgroup', tabtouch: 'tabgroup',
};

export function sameGroup(a: string, b: string): boolean {
  // The exchange is an independent price even under a shared owner.
  if (a.includes('betfair') || b.includes('betfair')) return false;
  const ga = GROUPS[a.toLowerCase()];
  const gb = GROUPS[b.toLowerCase()];
  return Boolean(ga) && ga === gb;
}

Step 5: the page

// app/page.tsx
import { fetchAflOdds } from '@/lib/odds';
import { bestPrices, bookPercentage, marginPct, arbProfitPct } from '@/lib/analysis';
import { sameGroup } from '@/lib/groups';
import OddsTable from '@/components/OddsTable';

export const revalidate = 60;

export default async function Page() {
  const events = await fetchAflOdds();

  const rows = events
    .map((ev) => {
      const h2h = ev.markets.find((m) => m.key === 'h2h');
      if (!h2h || h2h.selections.length < 2) return null;

      const best = bestPrices(h2h);
      const prices = best.map((b) => b.price);
      const books = best.map((b) => b.bookmaker);
      const crossGroup = !sameGroup(books[0], books[1]);

      return {
        eventId: ev.event_id,
        commenceTime: ev.commence_time,
        home: ev.home_team,
        away: ev.away_team,
        best,
        bookPct: bookPercentage(prices),
        margin: marginPct(prices),
        arb: crossGroup ? arbProfitPct(prices) : null,
        bookmakerCount: new Set(
          h2h.selections.flatMap((s) => s.prices.map((p) => p.bookmaker))
        ).size,
      };
    })
    .filter((r): r is NonNullable<typeof r> => r !== null)
    .sort((a, b) => a.commenceTime.localeCompare(b.commenceTime));

  return (
    <main className="mx-auto max-w-6xl p-6">
      <h1 className="text-2xl font-bold mb-1">AFL Odds Comparison</h1>
      <p className="text-sm text-zinc-400 mb-6">
        Best available price across every Australian bookmaker. Updated every 60 seconds.
      </p>
      <OddsTable rows={rows} />
    </main>
  );
}

Step 6: the table component

This is the only client component, and it exists purely so sorting does not require a round trip.

// components/OddsTable.tsx
'use client';

import { useState, useMemo } from 'react';

type Row = {
  eventId: string;
  commenceTime: string;
  home: string;
  away: string;
  best: { selection: string; price: number; bookmaker: string }[];
  bookPct: number;
  margin: number;
  arb: number | null;
  bookmakerCount: number;
};

type SortKey = 'time' | 'margin';

export default function OddsTable({ rows }: { rows: Row[] }) {
  const [sort, setSort] = useState<SortKey>('time');

  const sorted = useMemo(() => {
    const copy = [...rows];
    if (sort === 'margin') copy.sort((a, b) => a.margin - b.margin);
    else copy.sort((a, b) => a.commenceTime.localeCompare(b.commenceTime));
    return copy;
  }, [rows, sort]);

  return (
    <>
      <div className="flex gap-2 mb-4">
        <button
          onClick={() => setSort('time')}
          className={sort === 'time' ? 'font-bold' : 'text-zinc-400'}
        >
          By start time
        </button>
        <button
          onClick={() => setSort('margin')}
          className={sort === 'margin' ? 'font-bold' : 'text-zinc-400'}
        >
          By lowest margin
        </button>
      </div>

      <table className="w-full text-sm">
        <thead>
          <tr className="text-left text-zinc-400">
            <th className="py-2">Match</th>
            <th>Best home</th>
            <th>Best away</th>
            <th>Books</th>
            <th>Margin</th>
          </tr>
        </thead>
        <tbody>
          {sorted.map((r) => (
            <tr key={r.eventId} className="border-t border-zinc-800">
              <td className="py-3">
                <div>{r.home} v {r.away}</div>
                <div className="text-xs text-zinc-500">
                  {new Date(r.commenceTime).toLocaleString('en-AU', {
                    timeZone: 'Australia/Melbourne',
                    weekday: 'short', hour: 'numeric', minute: '2-digit',
                  })}
                </div>
              </td>
              {r.best.slice(0, 2).map((b) => (
                <td key={b.selection}>
                  <span className="font-semibold text-emerald-400">
                    ${b.price.toFixed(2)}
                  </span>
                  <span className="block text-xs text-zinc-500">{b.bookmaker}</span>
                </td>
              ))}
              <td>{r.bookmakerCount}</td>
              <td>
                {r.arb !== null ? (
                  <span className="rounded bg-emerald-700 px-2 py-0.5 text-xs text-white">
                    ARB +{r.arb.toFixed(2)}%
                  </span>
                ) : (
                  <span>{r.margin.toFixed(2)}%</span>
                )}
              </td>
            </tr>
          ))}
        </tbody>
      </table>
    </>
  );
}

Step 7: keeping it fresh

The server cache revalidates every 60 seconds, but an open browser tab will keep showing the HTML it was served. Add a small refresher:

// components/AutoRefresh.tsx
'use client';
import { useRouter } from 'next/navigation';
import { useEffect } from 'react';

export default function AutoRefresh({ seconds = 60 }: { seconds?: number }) {
  const router = useRouter();
  useEffect(() => {
    const id = setInterval(() => router.refresh(), seconds * 1000);
    return () => clearInterval(id);
  }, [router, seconds]);
  return null;
}

Because router.refresh() re-runs the server component and the server component hits the Next.js data cache, this costs you nothing extra upstream.

Performance and cost checklist

DecisionWhyCost impact
Fetch in a server componentKey stays server-side; one fetch serves all usersLarge
revalidate: 60Caps upstream calls at 60/hour regardless of trafficLarge
Single wide request over per-match requestsOne call returns the whole roundLarge
Client-side sort/filterInteraction without refetchingMedium
Staleness filter on pricesPrevents dead scrapers producing fake best pricesAccuracy, not cost
Throwing on non-200An outage looks like an outage, not an empty roundAccuracy

Extensions worth building next

  • Line and totals markets. Same structure, but you must group by line value before comparing — $1.90 at −6.5 is not comparable to $1.90 at −11.5.
  • Price movement sparklines. Pull from /v1/odds-history and render a small SVG per row.
  • Alerting. Persist each poll, diff against the previous snapshot, and notify when a price moves more than a threshold.
  • Player props. /v1/gameday/props extends the same table pattern to individual player markets.
  • Live opportunities. Swap polling for the SSE stream at /v1/stream/opportunities once you need sub-minute reaction.

If you want to see the finished version of this pattern running at scale before you build it, the Krok Odds AFL board is the same computation across 140+ books with movement history and opportunity detection layered on top.

Start building

The Krok Odds API covers AFL, NRL, racing and 30+ other competitions across 140+ Australian bookmakers. Free tier, no card required.

Read the API docs →
David Koval
About the author
David Koval
Senior Advantage Betting Contributor

David contributes long-form retrospectives, case studies, and operational pieces on AU advantage betting markets. His writing focuses on the realities of running a sustainable AU advantage operation — what works, what fails, and the operational details most blogs gloss over.