Architecture
A deep-dive into how the World Cup Pool is built, from the request path down to the database, plus the CI/CD topology and the domain model. Written for engineers extending or operating the system.
The World Cup Pool is a self-serve bracket competition for the 2026 FIFA World Cup. Friends create a pool, share a 6-character code, and each enters their predictions; a live leaderboard scores everyone as real results come in β no accounts, no passwords. It runs on fully managed infrastructure (Next.js on Vercel, Postgres on Neon), with privacy-friendly analytics, a daily automated database backup, and an end-to-end test suite that gates every deploy. An admin enters tournament results in a few clicks β or pulls them automatically from a live football-results API and confirms each change before it is applied.
- Framework
- Next.js 16 (App Router)
- Runtime
- React 19 Β· Node
- Data
- Prisma 6 Β· Neon Postgres
- Host
- Vercel β CI Β· CDN Β· Analytics
Operations
Vercel Web Analytics records page views via <Analytics /> in the root layout β privacy-friendly, no cookies.
A scheduled GitHub Action runs pg_dump every day, storing each snapshot as a 90-day downloadable artifact.
Playwright runs the full create β pick β leaderboard flow against a real Postgres in CI on every push, and the unit suite gates the production build β a failing test blocks the deploy.
1 Β· Runtime request path
Every page is a React Server Component that reads through a single Prisma client into Neon. Interactive surfaces hydrate as client components and mutate state through /api route handlers.
2 Β· CI/CD & deployment topology
main is branch-protected; all work lands via PRs. Each push builds on Vercel, and the build is gated by the unit-test suite before Next.js compiles.
3 Β· Data model
Four entities. Team is reference data seeded from data/worldcup2026.ts; the admin mutates only its result columns. A Pick is the join between a player and a team for a given round.
| id | cuid, PK |
| name | string |
| joinCode | string, unique (6-char) |
| locked | boolean β freezes picks |
| createdAt | datetime |
| id | cuid, PK β stored in cookie |
| displayName | string |
| poolId | FK β Pool (cascade) |
| joinedAt | datetime |
| @@unique | (poolId, displayName) |
| id | cuid, PK |
| playerId | FK β Player (cascade) |
| teamCode | FK β Team |
| round | GROUP|FINAL4|SEMIFINAL|WINNER |
| groupId | string, nullable β only for GROUP |
| @@unique | (playerId, round, groupId, teamCode) |
| code | string, PK (e.g. BRA) |
| name | string |
| group | "A".."L" |
| reachedRound | string, nullable β set by admin |
| wonGroup | boolean β set by admin |
| isChampion | boolean β set by admin |
4 Β· Key flows
Create / join a pool
- HomeClient POSTs to /api/pools or /api/pools/[code]/join.
- Route handler creates the Pool/Player and calls setPlayerIdCookie().
- wcpool_pid (httpOnly, 90-day) is written; client routes to /pools/[code].
Make / edit picks
- PicksClient builds the full pick set client-side with progressive reveal.
- Save POSTs the entire set to /api/pools/[code]/picks.
- Handler validates round counts, then $transaction([deleteMany, createMany]) β an atomic replace.
- Reset = POST an empty array β deletes all picks.
Admin enters results
- AdminClient submits to /api/admin/results with the ADMIN_TOKEN.
- Handler verifies the token server-side, then updates Team result columns.
- Locking a pool flips Pool.locked; the picks API then rejects writes.
Auto-fetch results (optional)
- Admin clicks Fetch latest results; AdminClient POSTs to /api/admin/fetch-results (token-gated, needs FOOTBALL_API_KEY).
- Server pulls standings + matches from football-data.org; lib/results.ts maps teams and derives flags β group winners only once a group is finished, plus Final-4, finalists, champion.
- The endpoint returns PROPOSED changes and writes nothing.
- Admin reviews the diff and confirms; changes save via /api/admin/results.
Leaderboard
- Server component loads players + picks + teams in one pass.
- scoreAllPicks() computes per-round and total points purely in memory.
- Rows sort by total desc, then alphabetically by name as a tie-break.
5 Β· Scoring engine
lib/scoring.ts is pure and deterministic β no I/O β which makes it trivial to unit test. Knockout rounds are cumulative: a team that advances further also satisfies the earlier rounds (a finalist still earns its Final-4 points; the champion earns all three). Correctness is round-specific:
| Round | Correct when⦠| Pts | à | Max |
|---|---|---|---|---|
| GROUP | team.wonGroup && team.group === pick.groupId | 1 | 12 | 12 |
| FINAL4 | team reached β₯ Final 4 (cumulative) | 4 | 4 | 16 |
| SEMIFINAL | team reached β₯ Semi-Final (cumulative) | 8 | 2 | 16 |
| WINNER | team.isChampion | 16 | 1 | 16 |
| Maximum achievable | 60 | |||
6 Β· Win probability (leaderboard)
The leaderboard's Win %column is each player's estimated chance of finishing 1st, from a Monte-Carlo simulation of the rest of the tournament. It appears only once a live knockout bracket is available.
- Bracket β
lib/tournament-bracket.tspulls the knockout matches from football-data.org (FOOTBALL_API_KEY), resolves each team to our codes, and builds the current bracket: the earliest undecided round is the frontier; winners of adjacent matches meet in the next round up to the final. Finished semifinals / final lock in milestones for already-eliminated teams. - Match model β
data/ratings.tsholds a FIFA/Elo rating per team;eloWinProbconverts a rating gap into a single-match win probability (logistic on the difference β a ~100-point edge β 64%). - Simulate β
lib/win-probability.tsplays the undecided matches ~20,000 times. Each trial yields a full set of team results, fed through the samescoreAllPicksthe leaderboard uses, then the players are ranked (a tie for 1st splits the credit). - Win % β the share of trials each player finished 1st. Computed server-side and cached (~10 min result, ~15 min bracket fetch) so a page view never re-runs the simulation or re-hits the API. An already-decided bracket skips the randomness β one deterministic pass.
Pure and unit-tested: the engine and bracket parser are covered by fixtures, including a frozen real-API snapshot. With no FOOTBALL_API_KEYor no knockout data, the column simply hides.
7 Β· Security & sessions
- No passwords. Identity = the
wcpool_pidcookie (httpOnly, SameSite=Lax). - The cookie alone grants nothing: every API route verifies the player belongs to the pool named in the URL.
- Admin writes are gated by a server-checked
ADMIN_TOKENenv var; the page UI never trusts the client. - Viewing another player's picks reuses
PicksClientin alocked(read-only) mode. - Picks close when the admin locks the pool or the global deadline in
lib/lock.tspasses (end of Jun 10, 2026) β enforced in both the picks API and UI.
8 Β· Connection & build notes
DATABASE_URLis the pooled (PgBouncer) Neon URL for app queries.DATABASE_URL_UNPOOLED(directUrl) is used for migrations.- Prisma is a global singleton to survive serverless function reuse and avoid connection storms.
- Pages use
export const dynamic = "force-dynamic"so picks/leaderboards are never statically cached. - Build:
prisma generate β vitest run β next buildβ a failing test blocks the deploy. - Vercel Web Analytics via
<Analytics />(@vercel/analytics) mounted in the root layout β privacy-friendly page-view metrics.
9 Β· Backups & durability
Pools, players, and picks exist only in Neon β the seed Team rows regenerate from data/worldcup2026.ts, but user data does not. After picks lock on Jun 10 that data is effectively frozen, so a snapshot at lock protects nearly everything.
On-demand (local)
npm run db:backupβ timestamped, gzippedpg_dumpinbackups/(git-ignored).npm run db:restore -- <file>restores (prompts first; dumps use--clean --if-exists).scripts/db-url.shresolves the direct/unpooled URL β best forpg_dump.
Automated (GitHub Actions)
.github/workflows/backup.ymlrunspg_dumpdaily + on demand.- Each dump is uploaded as a 90-day workflow artifact.
- Needs a
DATABASE_URL_UNPOOLEDrepo secret to run. - Neon point-in-time restore is a short-window safety net; these dumps are the durable copy.
10 Β· Repository map
app/
layout.tsx root layout Β· mounts <Analytics/>
page.tsx Β· HomeClient.tsx create / join a pool
pools/[code]/
page.tsx pool dashboard
picks/ page.tsx Β· PicksClient progressive bracket picker
leaderboard/page.tsx scored standings + win % (server-computed)
how-it-works/ Β· architecture/ static reference pages
admin/ page.tsx Β· AdminClient results entry (token-gated)
api/
pools/route.ts create pool
pools/[code]/join/route.ts join pool
pools/[code]/picks/route.ts atomic replace of a player's picks
admin/results/route.ts set team results / lock pool
admin/fetch-results/route.ts pull live results from football-data.org
components/ Navigation (client Β· active-link) Β· HeroBanner Β· ThemeToggle
lib/ db.ts (Prisma singleton) Β· session.ts (cookie)
scoring.ts (pure, cumulative) Β· lock.ts (pick deadline)
results.ts (map providers + derive fetched results)
win-probability.ts Β· tournament-bracket.ts (Elo Monte-Carlo)
data/ worldcup2026.ts (48 teams, rounds, points) Β· ratings.ts (Elo)
prisma/ schema.prisma Β· seed.ts
scripts/ backup-db.sh Β· restore-db.sh Β· db-url.sh
.github/ workflows/backup.yml (scheduled pg_dump)
__tests__/ vitest unit + component tests
e2e/ playwright smoke tests