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.
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:
- See all 12 groups with their 4 teams
- Pick a winner for each of the 72 group-stage matches
- See live standings update as they pick
- Watch the auto-generated knockout bracket fill in
- Pick winners through the knockouts to the Final
- 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.
Ready to Power Your Sports App?
Start your 7-day free trial. All endpoints included on every plan.