A bracket pool for the 2026 FIFA World Cup. Anyone with a 6-character pool code can join with a display name, make picks, and see a live leaderboard. No accounts, no passwords.
- Stack: Next.js 16, React 19, Tailwind v4, Prisma + Postgres (Neon)
- Hosting: Vercel (free tier is plenty)
- Auth: name + pool code (cookie-based player session)
- Scoring: Group winner=1, Final 4=4, Semi-Final=8, Winner=16 (max 60)
npm install
cp .env.example .env.local
# Fill in DATABASE_URL with a Postgres connection string (Neon, local Postgres, etc.)
npm run db:push # create tables
npm run db:seed # load the 48 teams + 12 groups
npm run dev # http://localhost:3000npm run test # Vitest unit + component tests (jsdom)
npm run test:watch # Vitest in watch mode
npm run test:e2e # Playwright smoke tests (auto-starts a dev server)- Unit/component tests live in
__tests__/— pure logic (lib/scoring.ts,data/worldcup2026.ts, join-code generation) plus React component render tests via@testing-library/react. - End-to-end tests live in
e2e/— public-page smoke tests (home, how-it-works, architecture, nav) plus a full create-pool → picks → leaderboard flow (flow.spec.ts) that exercises the real DB. The flow runs in CI against an ephemeral Postgres service (.github/workflows/e2e.yml); locally it needs a throwaway database (don't point it at production). Runnpx playwright install chromiumonce before the first e2e run. npm run buildruns the unit suite beforenext build, so a failing test blocks the production deploy.
gh repo create worldcup-pool --public --source=. --remote=origin --push- Go to https://vercel.com/new and import
JamesPortman/worldcup-pool. - Framework preset: Next.js (auto-detected).
- Don't deploy yet — set up the database first.
- In the new Vercel project, go to Storage → Create Database → Neon (Postgres).
- Pick a region close to your users (e.g.
iad1/Washington for North America). - Vercel auto-creates
DATABASE_URL+ related env vars in the project. No copy/paste needed.
In Settings → Environment Variables, add:
| Key | Value | Environments |
|---|---|---|
ADMIN_TOKEN |
a long random string (you choose) | Production, Preview |
You'll need this string on the /admin page to enter results and lock pools.
Click Deploy. The build runs prisma generate && vitest run && next build — unit tests must pass before Next.js compiles.
After the first successful deploy, push the Prisma schema to Neon and seed the teams. From your laptop:
# Pull the production env vars into a local .env file
npx vercel env pull .env.production.local
# Push schema and seed teams using that env
DATABASE_URL=$(grep '^DATABASE_URL=' .env.production.local | cut -d= -f2- | tr -d '"') npm run db:push
DATABASE_URL=$(grep '^DATABASE_URL=' .env.production.local | cut -d= -f2- | tr -d '"') npm run db:seed(npx vercel env pull will prompt you to install + link the project the first time.)
- Open the deployed URL → create a pool → share the join code.
- Visit
/adminand paste yourADMIN_TOKENto mark teams as advancing or lock a pool.
- Before kickoff: each player joins with the code and submits picks. Picks can be edited until you lock the pool from
/admin. - After each round: in
/admin, set each surviving team's "reached round" to the round they reached. Check won group for the 12 group winners after the group stage. Check champion for the team that wins the final. The leaderboard updates instantly. - Auto-fetch results (optional): in
/admin, the "Fetch latest results" button pulls live standings + knockout results from football-data.org and stages the changes for you to review (group winners, Final-4, finalists, champion) — nothing is saved until you click Apply. It maps the provider's teams to ours by 3-letter code / name; any it can't match are flagged so you can set them by hand. Enable it by setting a freeFOOTBALL_API_KEYenv var on Vercel (the World Cup competition is on the free tier).
Pools, players, and picks live only in Neon, so keep your own dumps — especially right after picks lock on June 10, when the user data is effectively frozen.
Where to get the connection string: Vercel stores the database vars as sensitive (write-only), so
vercel env pullreturns them empty and the dashboard won't reveal them. Copy the URL from the Neon console instead: Project → Connection Details → turn the "Pooled connection" toggle off to get the direct URL (host has no-pooler), e.g.postgresql://…@ep-xxxx.us-east-1.aws.neon.tech/neondb?sslmode=require.
On demand (local):
export DATABASE_URL_UNPOOLED="postgresql://…neon.tech/neondb?sslmode=require" # from Neon
npm run db:backup # → backups/worldcup-<timestamp>.sql.gz
npm run db:restore -- backups/worldcup-<timestamp>.sql.gz # restore (OVERWRITES the target!)Requires the Postgres client tools: brew install libpq && brew link --force libpq.
(You can also drop DATABASE_URL_UNPOOLED=… into .env.production.local instead
of exporting it.)
Automated (GitHub Actions): .github/workflows/backup.yml runs pg_dump
daily (and on demand) and stores each dump as a 90-day workflow artifact.
Activate it by adding a repository secret DATABASE_URL_UNPOOLED (the direct
Neon URL above) under GitHub → Settings → Secrets and variables → Actions —
or gh secret set DATABASE_URL_UNPOOLED --body "<paste-neon-url>". Then trigger a
run from the Actions tab to verify, and download the artifact from the run page.
Neon also offers point-in-time restore from its console, but the free-plan window is short — treat these dumps as the durable copy.
- Sentry is scaffolded (
instrumentation.ts,instrumentation-client.ts,app/global-error.tsx) but off by default — it's a no-op until you set the DSN env vars on Vercel:SENTRY_DSN(server) andNEXT_PUBLIC_SENTRY_DSN(client), from a Sentry project. (Source-map upload viawithSentryConfigcan be added later with aSENTRY_AUTH_TOKEN.) - Rate limiting (
lib/rate-limit.ts) guards pool creation (5/min) and joining (15/min) per client IP. It's an in-memory, best-effort, per-instance limiter — for a hard global limit, back it with Upstash Redis (the call sites stay the same).
If FIFA changes a team (e.g. playoff resolution), edit data/worldcup2026.ts and run:
npm run db:seedThe seed script upserts, so it won't wipe existing picks.