Krok Odds
Guide

How to Build a Next-to-Go Racing Widget

Build a next-to-go racing widget with live odds, runner names, barriers and jockey data. Full tutorial with code examples.

David Koval
David Koval
Senior Advantage Betting Contributor
11 min read·Published 25 Aug 2026

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.

LevelKey fieldsUsed for
Meetingvenue, venue_slug, race_type, jurisdictionDisplay name, state filter, code badge
Racerace_number, start_time, distance, nameSorting, countdown, subtitle
Runnernumber, name, barrier, jockey, scratched, oddsThe 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

TrapSymptomFix
UTC date for the racing dayEvening card vanishes after 10am UTCCompute the date in Australia/Melbourne
Server-rendered relative timeHydration mismatch warningsPlaceholder until useEffect runs
Scratched runners in the top threeWidget shows a $1.80 favourite that is not runningFilter on the scratched flag at render
Stale prices from a broken scraperA dead book keeps "winning" best priceDiscard prices older than ~15 minutes
Cross-code venue name collisionsSale gallops and Sale greyhounds merge into one meetingKey on venue_slug + race_type, never venue alone
Fetching per-race instead of per-dayQuota gone by lunchtimeOne 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/movers flags 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-form gives recent starts, and it pairs well with a hover card.
  • Racing arbitrage — /v1/racing/arbs returns 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 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.