Every Australian racing site has a next-to-go widget in the top-right corner, and they all look roughly the same for a reason: it is the component that answers a racing punter's only real-time question. With races jumping every couple of minutes across thoroughbred, harness and greyhound meetings, "what is next" is the whole navigation model.
It is also a deceptively good engineering exercise. You have to merge multiple race types, handle timezone-correct countdowns without hydration errors, deal with scratchings, and keep it fresh without hammering an API. This tutorial builds one end to end.
What we are building
- A list of the next N races across all Australian meetings
- Race type badges for thoroughbred, harness and greyhound
- A live countdown to jump, updating every second
- Top three runners with best available price
- Automatic removal of races once they jump
- A 45-second server refresh that does not scale with traffic
Step 1: understand the data shape
Racing data is three levels deep — meeting, race, runner — and each level carries fields you need.
| Level | Key fields | Used for |
|---|---|---|
| Meeting | venue, venue_slug, race_type, jurisdiction | Display name, state filter, code badge |
| Race | race_number, start_time, distance, name | Sorting, countdown, subtitle |
| Runner | number, name, barrier, jockey, scratched, odds | The actual list of things to bet on |
Note race_type: the Krok Odds racing family normalises to T (thoroughbred), H (harness) and G (greyhound). Some upstream feeds use R for thoroughbreds; the API maps it so you do not have to.
Step 2: types
// lib/racing-types.ts
export type RaceType = 'T' | 'H' | 'G';
export interface Runner {
number: number;
name: string;
barrier?: number;
jockey?: string;
trainer?: string;
scratched?: boolean;
odds?: { bookmaker: string; price: number }[];
}
export interface Race {
race_number: number;
name?: string;
start_time: string; // ISO 8601 UTC
distance?: number;
runners: Runner[];
}
export interface Meeting {
venue: string;
venue_slug: string;
race_type: RaceType;
jurisdiction?: string;
races: Race[];
}
/** Flattened row the widget actually renders. */
export interface NextToGoRace {
id: string;
venue: string;
raceType: RaceType;
raceNumber: number;
startTime: string;
distance?: number;
topRunners: { number: number; name: string; price: number | null }[];
}Step 3: fetch and flatten
The API gives you a meeting-shaped tree. The widget wants a flat, time-sorted list. Do the flattening once, on the server.
// lib/next-to-go.ts
import type { Meeting, NextToGoRace, Runner } from './racing-types';
const BASE = 'https://krokodds.com.au/api/v1';
function bestPrice(runner: Runner): number | null {
if (!runner.odds?.length) return null;
return runner.odds.reduce((max, o) => (o.price > max ? o.price : max), 0) || null;
}
function todayInMelbourne(): string {
// Racing days are local dates, not UTC dates. Getting this wrong drops the
// first two hours of the card every single evening.
return new Intl.DateTimeFormat('en-CA', {
timeZone: 'Australia/Melbourne',
year: 'numeric', month: '2-digit', day: '2-digit',
}).format(new Date());
}
export async function fetchNextToGo(limit = 8): Promise<NextToGoRace[]> {
const date = todayInMelbourne();
const res = await fetch(`${BASE}/racing/meetings?date=${date}&limit=100`, {
headers: { 'X-API-Key': process.env.KROK_API_KEY ?? '' },
next: { revalidate: 45 },
});
if (!res.ok) throw new Error(`racing meetings ${res.status}`);
const { data } = (await res.json()) as { data: Meeting[] };
const now = Date.now();
return data
.flatMap((meeting) =>
meeting.races.map((race) => ({
id: `${meeting.venue_slug}-${race.race_number}`,
venue: meeting.venue,
raceType: meeting.race_type,
raceNumber: race.race_number,
startTime: race.start_time,
distance: race.distance,
topRunners: race.runners
.filter((r) => !r.scratched)
.map((r) => ({ number: r.number, name: r.name, price: bestPrice(r) }))
.sort((a, b) => (a.price ?? 999) - (b.price ?? 999))
.slice(0, 3),
}))
)
.filter((race) => new Date(race.startTime).getTime() > now)
.sort((a, b) => a.startTime.localeCompare(b.startTime))
.slice(0, limit);
}Two details there are load-bearing. todayInMelbourne() exists because a racing day is a local calendar date — computing the date in UTC drops the entire evening card once you pass 10am UTC. And sorting runners by price before slicing gives you the market's three most likely winners rather than saddlecloths 1, 2 and 3.
Step 4: the countdown, without hydration errors
This is where most implementations break. If the server renders "2m 40s" and the client hydrates a beat later rendering "2m 39s", React logs a mismatch and in some cases discards the server HTML entirely.
The fix: render nothing time-relative until after mount.
// components/Countdown.tsx
'use client';
import { useEffect, useState } from 'react';
function format(ms: number): string {
if (ms <= 0) return 'JUMPED';
const total = Math.floor(ms / 1000);
const m = Math.floor(total / 60);
const s = total % 60;
if (m >= 60) return `${Math.floor(m / 60)}h ${m % 60}m`;
return m > 0 ? `${m}m ${String(s).padStart(2, '0')}s` : `${s}s`;
}
export default function Countdown({ startTime }: { startTime: string }) {
const target = new Date(startTime).getTime();
const [label, setLabel] = useState<string | null>(null);
useEffect(() => {
const tick = () => setLabel(format(target - Date.now()));
tick();
const id = setInterval(tick, 1000);
return () => clearInterval(id);
}, [target]);
// Server render and first client render both produce the placeholder,
// so hydration matches exactly.
if (label === null) {
return <span className="tabular-nums text-zinc-500">--:--</span>;
}
const urgent = target - Date.now() < 120_000;
return (
<span className={urgent ? 'tabular-nums text-amber-400' : 'tabular-nums text-zinc-300'}>
{label}
</span>
);
}Step 5: the widget
// components/NextToGo.tsx
import Countdown from './Countdown';
import type { NextToGoRace, RaceType } from '@/lib/racing-types';
const TYPE_LABEL: Record<RaceType, string> = { T: 'Gallops', H: 'Harness', G: 'Greyhounds' };
const TYPE_CLASS: Record<RaceType, string> = {
T: 'bg-emerald-900 text-emerald-300',
H: 'bg-sky-900 text-sky-300',
G: 'bg-violet-900 text-violet-300',
};
export default function NextToGo({ races }: { races: NextToGoRace[] }) {
if (races.length === 0) {
return <p className="text-sm text-zinc-500">No races remaining today.</p>;
}
return (
<ul className="divide-y divide-zinc-800 rounded-lg border border-zinc-800">
{races.map((race) => (
<li key={race.id} className="flex items-center gap-3 p-3">
<span className={`rounded px-1.5 py-0.5 text-[10px] font-bold ${TYPE_CLASS[race.raceType]}`}>
{TYPE_LABEL[race.raceType]}
</span>
<div className="min-w-0 flex-1">
<div className="truncate text-sm font-medium text-white">
{race.venue} R{race.raceNumber}
{race.distance ? <span className="text-zinc-500"> · {race.distance}m</span> : null}
</div>
<div className="truncate text-xs text-zinc-500">
{race.topRunners
.map((r) => `${r.number}. ${r.name}${r.price ? ` $${r.price.toFixed(2)}` : ''}`)
.join(' ')}
</div>
</div>
<Countdown startTime={race.startTime} />
</li>
))}
</ul>
);
}Step 6: wire it into a page
// app/page.tsx
import { fetchNextToGo } from '@/lib/next-to-go';
import NextToGo from '@/components/NextToGo';
export const revalidate = 45;
export default async function Page() {
const races = await fetchNextToGo(8);
return (
<main className="mx-auto max-w-md p-6">
<h2 className="mb-3 text-lg font-bold text-white">Next to go</h2>
<NextToGo races={races} />
</main>
);
}Step 7: dropping races once they jump
The server list is 45 seconds stale at worst, which means a jumped race can linger. Two options, and the cheap one is usually right.
- Cheap: let the countdown display "JUMPED" and let the next server revalidation remove it. Costs nothing, looks slightly untidy for under a minute.
- Better: filter client-side in a wrapper component that holds the list in state and drops entries whose start time has passed, then triggers
router.refresh()to backfill.
'use client';
import { useEffect, useState } from 'react';
import { useRouter } from 'next/navigation';
export function useLiveRaces(initial: NextToGoRace[]) {
const [races, setRaces] = useState(initial);
const router = useRouter();
useEffect(() => setRaces(initial), [initial]);
useEffect(() => {
const id = setInterval(() => {
const now = Date.now();
setRaces((prev) => {
const live = prev.filter((r) => new Date(r.startTime).getTime() > now);
// Ran low on races — pull a fresh page from the server.
if (live.length < 4) router.refresh();
return live;
});
}, 5000);
return () => clearInterval(id);
}, [router]);
return races;
}Things that will bite you
| Trap | Symptom | Fix |
|---|---|---|
| UTC date for the racing day | Evening card vanishes after 10am UTC | Compute the date in Australia/Melbourne |
| Server-rendered relative time | Hydration mismatch warnings | Placeholder until useEffect runs |
| Scratched runners in the top three | Widget shows a $1.80 favourite that is not running | Filter on the scratched flag at render |
| Stale prices from a broken scraper | A dead book keeps "winning" best price | Discard prices older than ~15 minutes |
| Cross-code venue name collisions | Sale gallops and Sale greyhounds merge into one meeting | Key on venue_slug + race_type, never venue alone |
| Fetching per-race instead of per-day | Quota gone by lunchtime | One wide request, cached, flattened locally |
That venue-collision one is genuinely nasty and specific to Australian racing: several towns host both thoroughbred and greyhound meetings under the same name, and any code that keys on venue slug alone will silently merge them. Always carry race type in the key.
Extending it
- Full fields on click — expand a race to the complete runner list with barriers, jockeys and every bookmaker price.
- Money movement —
/v1/racing/moversflags runners whose price has firmed or drifted sharply since market open. - Betfair overlay — show the exchange price alongside fixed odds so users can see where the corporate books are short.
- Form popovers —
/v1/racing/runner-formgives recent starts, and it pairs well with a hover card. - Racing arbitrage —
/v1/racing/arbsreturns cross-book opportunities already computed.
For a reference implementation of all of the above running live across every Australian meeting, the Krok Odds racing board is the same data model with full fields, movers and exchange prices layered on. The endpoint reference is at /api-docs.
Racing data for developers
Meetings, races, runners, form, sectionals and per-book prices across every Australian thoroughbred, harness and greyhound meeting. Free tier available.
Get an API key →
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.