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.
- Framework
- Next.js 16 (App Router)
- Runtime
- React 19 · Node
- Data
- Prisma 6 · Neon Postgres
- Host
- Vercel — CI · CDN · Analytics
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.
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 · 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.
7 · 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.
8 · 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.
9 · 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 (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
components/ Navigation (client · active-link) · HeroBanner · ThemeToggle
lib/ db.ts (Prisma singleton) · session.ts (cookie)
scoring.ts (pure, cumulative) · lock.ts (pick deadline)
data/ worldcup2026.ts (48 teams, rounds, points)
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