A small webapp to run a World Cup 2026 prediction pool with colleagues. Players predict match scores and a knockout bracket; a live leaderboard updates as real results come in from football-data.org.
- Lightweight login — just a display name, with an optional PIN to protect it. No OAuth.
- Score predictions for every match (Kicktipp-style: exact 4 / goal difference 3 / correct result 2). Each match locks at kickoff.
- Bracket predictions — pick which teams reach each knockout round (R16 → Champion), with escalating points. Locks when the knockout stage begins.
- Automatic results synced from football-data.org via a scheduled job, with an admin manual override that the sync never overwrites.
- Leaderboard ranked by total points, then exact scores, then join order.
- Admin panel — sync data, enter/override results, tune scoring, manage players.
Next.js (App Router) · TypeScript · Tailwind CSS · Drizzle ORM · Neon serverless Postgres · iron-session · Zod. Deploys to Vercel.
-
Install dependencies
pnpm install
-
Create a Postgres database (e.g. a free Neon project) and copy the connection string.
-
Get a football-data.org token (free) at https://www.football-data.org/client/register.
-
Configure environment — copy
.env.exampleto.env.localand fill in:cp .env.example .env.local
DATABASE_URL— Neon connection stringFOOTBALL_DATA_TOKEN— your API tokenSESSION_PASSWORD— ≥32 random chars (openssl rand -base64 32)CRON_SECRET— protects the sync endpoint (openssl rand -hex 16)ADMIN_NAMES— comma-separated display names to seed as admins
-
Create the schema and seed
pnpm db:push # apply the schema to your database pnpm seed # create the settings row + admin user(s)
-
Run
pnpm dev
Log in with one of your
ADMIN_NAMES, open Admin → Sync now to load fixtures, then start predicting.
| Command | Description |
|---|---|
pnpm dev |
Start the dev server |
pnpm build / pnpm start |
Production build / serve (migrations run at server boot) |
pnpm typecheck |
tsc --noEmit |
pnpm lint |
ESLint |
pnpm test |
Unit tests for the scoring logic |
pnpm db:generate |
Generate a SQL migration from the schema |
pnpm db:push |
Push the schema directly to the DB (good for dev) |
pnpm db:migrate |
Apply generated migrations |
pnpm db:studio |
Drizzle Studio |
pnpm seed |
Seed settings + admin users |
- Import the repo into Vercel and set the four env vars (
DATABASE_URL,FOOTBALL_DATA_TOKEN,SESSION_PASSWORD,CRON_SECRET). - Deploy — pending Drizzle migrations run automatically when the server starts. If the database was originally created with
db:push, the migrator baselines existing schema before applying only new migrations. Runpnpm seedonce on a fresh database. vercel.jsonregisters a daily cron that calls/api/cron/sync. On the Hobby plan crons run once per day — during the tournament you can either hit Admin → Sync now manually on match days, or point a free external scheduler (cron-job.org / a GitHub Actions cron) athttps://<your-app>/api/cron/sync?secret=<CRON_SECRET>every ~10 minutes.
The dashboard and leaderboard refresh themselves while open and pull live scores
without anyone clicking anything. Crucially, the football-data.org call is
throttled globally in the database, not per browser: every open client polls
/api/live, but the upstream API is hit at most once per liveSyncSeconds
(default 30s, set in Admin → Scoring settings) thanks to an atomic
compare-and-swap on a shared lastSyncedAt timestamp. So 1 player or 50 players
with the app open produces the same ~2 calls/minute — well within the paid tier's
20 calls/min. In-play scores are stored as they update, but points are only
awarded once a match is FINISHED.
Match (per finished game) — the single highest matching tier is awarded: exact score (4), correct goal difference (3), correct result (2), otherwise 0. Knockout games are scored on the post-extra-time result; penalties decide bracket advancement only.
Bracket — for each round you pick the teams you think will reach it. You score the round's points for every pick that actually got there: Round of 16 (2), quarter-finalist (4), semi-finalist (6), finalist (8), champion (12). All point values are editable in Admin → Scoring settings, which recomputes the leaderboard.
app/ Pages, server actions, and the cron route
actions/ auth, predictions, admin server actions
predict/matches score prediction UI
predict/bracket bracket prediction UI
admin/ sync, results, settings, users
api/cron/sync scheduled result sync endpoint
components/ Nav, app shell, shared UI
db/ Drizzle schema, client, migrations
lib/ session, football-api client, scoring, queries
scripts/seed.ts settings + admin seeding