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-oddsAdd your key to .env.local:
KROK_API_KEY=your_key_hereNo 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
| Decision | Why | Cost impact |
|---|---|---|
| Fetch in a server component | Key stays server-side; one fetch serves all users | Large |
revalidate: 60 | Caps upstream calls at 60/hour regardless of traffic | Large |
| Single wide request over per-match requests | One call returns the whole round | Large |
| Client-side sort/filter | Interaction without refetching | Medium |
| Staleness filter on prices | Prevents dead scrapers producing fake best prices | Accuracy, not cost |
| Throwing on non-200 | An outage looks like an outage, not an empty round | Accuracy |
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-historyand 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/propsextends the same table pattern to individual player markets. - Live opportunities. Swap polling for the SSE stream at
/v1/stream/opportunitiesonce 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 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.