TUTORIAL

How to Build a World Cup 2026 Bracket Predictor (Step-by-Step)

Build a World Cup 2026 bracket predictor app from scratch with React and a REST API. Group stage simulator + knockout bracket + scoring.

7 min read

A bracket predictor is the perfect World Cup product: fans love them, they're shareable, and the math is simple enough to build in a weekend. This tutorial walks through building a complete World Cup 2026 bracket predictor using React + a REST API for the structure, plus pure client-side logic for predictions.

By the end you'll have a working app that lets users pick winners through the 48-team group stage and into the Round of 32 → Final.

What we're building

A single-page app where users:

  1. See all 12 groups with their 4 teams
  2. Pick a winner for each of the 72 group-stage matches
  3. See live standings update as they pick
  4. Watch the auto-generated knockout bracket fill in
  5. Pick winners through the knockouts to the Final
  6. Get a shareable link to their bracket

We'll use Next.js for the framework, TheStatsAPI for the static structure (fixtures + teams), and zero backend for the actual predictions — everything stays in localStorage so the app costs $0 to run.

Step 1: Fetch the fixture structure

The 2026 World Cup has 104 matches. We need them once at build time:

// lib/world-cup.ts
export async function fetchWorldCupFixtures() {
  const response = await fetch(
    'https://api.thestatsapi.com/api/football/matches?competition_id={COMPETITION_ID}&season=2026&per_page=104',
    { headers: { 'Authorization': `Bearer ${process.env.STATS_API_KEY}` } }
  );
  const { data } = await response.json();
  return data;
}

Call this in getStaticProps (Next.js Pages router) or in a Server Component (App router) — fixtures change rarely, so cache for the day.

Step 2: Render the 12 groups

For each group letter A-L, render a card with 4 teams and 6 fixtures:

function GroupCard({ letter, teams, fixtures, picks, onPick }) {
  return (
    <div className="border rounded p-4">
      <h3>Group {letter}</h3>
      <ul>
        {teams.map((t) => <li key={t.slug}>{t.name}</li>)}
      </ul>
      <div className="space-y-2 mt-3">
        {fixtures.map((f) => (
          <MatchPicker
            key={f.id}
            fixture={f}
            pick={picks[f.id]}
            onPick={(homeScore, awayScore) => onPick(f.id, homeScore, awayScore)}
          />
        ))}
      </div>
    </div>
  );
}

MatchPicker is two number inputs for home and away score.

Step 3: Compute live standings from picks

This is the meat of the simulator. Given the user's score predictions, calculate the table client-side:

type StandingRow = {
  team: string;
  played: number;
  won: number;
  drawn: number;
  lost: number;
  gf: number;
  ga: number;
  gd: number;
  points: number;
};

function computeStandings(fixtures, picks, groupTeams): StandingRow[] {
  const rows = Object.fromEntries(
    groupTeams.map((t) => [t.slug, {
      team: t.name, played: 0, won: 0, drawn: 0, lost: 0,
      gf: 0, ga: 0, gd: 0, points: 0,
    }])
  );

  for (const f of fixtures) {
    const pick = picks[f.id];
    if (!pick || pick.home == null || pick.away == null) continue;

    const home = rows[f.home.slug];
    const away = rows[f.away.slug];
    if (!home || !away) continue;

    home.played++; away.played++;
    home.gf += pick.home; home.ga += pick.away;
    away.gf += pick.away; away.ga += pick.home;

    if (pick.home > pick.away) { home.won++; home.points += 3; away.lost++; }
    else if (pick.home < pick.away) { away.won++; away.points += 3; home.lost++; }
    else { home.drawn++; away.drawn++; home.points++; away.points++; }
  }

  return Object.values(rows)
    .map((r) => ({ ...r, gd: r.gf - r.ga }))
    .sort((a, b) =>
      b.points - a.points
      || b.gd - a.gd
      || b.gf - a.gf
      || a.team.localeCompare(b.team)
    );
}

FIFA's actual tiebreakers go deeper (head-to-head, fair play) but for a prediction game the points/GD/GS/alphabetical fallback is sufficient.

We built a fully working version of this as a free standalone tool: World Cup Group Simulator. Steal the math from the source if you're stuck.

Step 4: Propagate winners into the Round of 32

Once a group is fully picked, the top 2 advance. In the 48-team format, the 8 best third-placed teams also advance — we'll keep this simple and ignore the third-place qualification for now (you can add it as a v2).

function getQualifiers(standings: Record<string, StandingRow[]>) {
  const winners = [];
  const runnersUp = [];
  for (const [letter, table] of Object.entries(standings)) {
    if (table.length >= 2 && table[0].played === 3 && table[1].played === 3) {
      winners.push({ group: letter, position: 1, team: table[0].team });
      runnersUp.push({ group: letter, position: 2, team: table[1].team });
    }
  }
  return { winners, runnersUp };
}

Use these to fill in the knockout bracket pairings. The FIFA-defined R32 pairings (which group winner plays which runner-up) are static — hardcode them into a lookup table.

Step 5: Persist to localStorage

A 50-line predictor with no backend looks like:

useEffect(() => {
  const saved = localStorage.getItem('wc2026-picks');
  if (saved) setPicks(JSON.parse(saved));
}, []);

useEffect(() => {
  localStorage.setItem('wc2026-picks', JSON.stringify(picks));
}, [picks]);

Users come back to their picks across sessions. Zero infrastructure cost.

Step 6: Generate a shareable link

Encode the picks dictionary into a URL parameter using lz-string (compresses better than base64 for JSON):

import LZString from 'lz-string';

function shareUrl(picks) {
  const encoded = LZString.compressToEncodedURIComponent(JSON.stringify(picks));
  return `${window.location.origin}/bracket?picks=${encoded}`;
}

A friend clicks the link, sees your bracket, can make their own picks on top. Viral by default.

Step 7: Add a scoring system (optional)

Once the actual World Cup starts, poll for real results and compare against each user's picks:

async function getRealResults() {
  const r = await fetch(
    'https://api.thestatsapi.com/api/football/matches?competition_id={COMPETITION_ID}&status=finished',
    { headers }
  );
  return (await r.json()).data;
}

function scorePicks(picks, results) {
  let points = 0;
  for (const r of results) {
    const pick = picks[r.id];
    if (!pick) continue;
    const realResult = sign(r.home_score - r.away_score);
    const pickedResult = sign(pick.home - pick.away);
    if (realResult === pickedResult) points += 1;  // got the result right
    if (pick.home === r.home_score && pick.away === r.away_score) points += 2;  // exact score
  }
  return points;
}

Standard fantasy scoring: 1 point for the result, 3 points for the exact score (1 + 2 bonus). Adjust as needed.

Putting it all together

A complete bracket predictor takes roughly:

  • Day 1: fetch fixtures, render groups, basic picker UI
  • Day 2: standings calculation + knockout bracket
  • Day 3: shareable links + polish + deploy

If you want a head start, our free World Cup Bracket Maker is a working reference implementation. Inspect-source friendly.

Going further

Things that turn a hobby project into something fans share:

  • Leaderboards — store each user's bracket hash in a Supabase table; sort by score
  • Push notifications — let users opt in for "your bracket is in the lead!" alerts
  • Group brackets — let friends create a shared pool with a join code
  • Live mode — once the tournament starts, lock picks and show real results inline
  • Bracket comparisons — diff your bracket against a friend's, see where you differ

Frequently Asked Questions

Do I need a paid API for a bracket app?

For the fixture structure, no — football-data.org's free tier covers World Cup fixtures. For live results scoring during the tournament, the rate limit will hurt. TheStatsAPI's Starter plan ($50/month) is the cleanest paid option.

How do I handle the new Round of 32 format?

The R32 has 16 matches with 24 group qualifiers (12 winners + 12 runners-up) plus 8 best third-placed teams. For a simple bracket, you can skip the third-place qualification logic and let users pick from group winners/runners-up only. The full FIFA rule is on Wikipedia.

Where can I host a bracket app for free?

Vercel or Netlify free tiers handle a typical bracket app fine. Static-export friendly stacks (Next.js with output: 'export') work on GitHub Pages too. The whole app is client-side after the initial fixture fetch.

How do I prevent users from changing picks once matches start?

Compare the current time to each match's kickoff_utc. Disable inputs once the time has passed:

const isLocked = (fixture) => new Date(fixture.kickoff_utc) <= new Date();

Can I monetise a bracket app?

Yes — affiliate links to betting sites, sponsored brackets, paid "premium" leagues with prize pools, or simple display ads. World Cup search volume peaks in June/July so timing matters.

What if I just want to play, not build?

Use our free World Cup Group Simulator or World Cup Bracket Maker. Both are pure client-side, no signup, no API needed.

Start building today

Ready to Power Your Sports App?

Start your 7-day free trial. All endpoints included on every plan.

Cancel anytime
7-day free trial
Setup in 5 minutes