Real teams missing from TEAM_ISO: Bosnia-Herzegovina (ba), Kosovo (xk), New Caledonia (nc), Suriname (sr). Defunct/dissolved with no flag-icons code: Serbia and Montenegro (cs retired), Zaire (zr retired) → null. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
World Cup
A full-stack World Cup statistics web app covering every tournament from 1930 to 2026. Built with Next.js 16, TailwindCSS 4, GraphQL, and PostgreSQL. Data is sourced from openfootball/worldcup.json and synced on a schedule so live 2026 results appear within minutes.
Features
- Live 2026 matches — detected automatically when today's date matches a scheduled fixture; Apollo polls every 60 seconds for score updates
- All-time statistics — goals, hat-tricks, biggest wins, highest-scoring games, penalty stats, goals-by-minute heatmap, confederation performance, title counts
- Group standings — computed from match results for every tournament, pre-seeded from openfootball's standings files where available
- Deep-linked pages — every tournament, team, and player has a permanent URL (
/tournaments/1966,/teams/brazil,/players/Pelé) - Full-text search — across teams, tournaments, and players
- Squad data — 26-man rosters for 2026 with position, shirt number, and date of birth
- Qualification playoffs — 2026 inter-confederation playoff results stored separately
- Country flags — via
flag-iconsCSS classes, ~200 nations covered - Dark pitch aesthetic — Bebas Neue headings, Space Grotesk body, green-on-black design
Pages
| Route | Content |
|---|---|
/ |
Home: live matches, stat pills, latest result, upcoming fixtures, Golden Boot race |
/groups |
All 12 group tables for 2026 (P/W/D/L/GD/Pts) |
/stats |
Historical stats: goals chart, top scorers, hat-tricks, biggest wins, goals by minute, ET/shootout stats, confederation stats |
/history |
All 23 tournament cards newest-first, each with host, winner, top scorer |
/search?q=… |
Full-text search across teams, players, tournaments |
/tournaments/[year] |
Tournament detail: group stage with standings + matches, knockout rounds, scorer sidebar |
/teams/[slug] |
Team profile: all-time record, top scorers, WC appearances |
/players/[name] |
Player profile: goals by tournament, penalties vs open play breakdown |
Tech stack
| Layer | Technology |
|---|---|
| Framework | Next.js 16.2 (App Router, standalone output) |
| Styling | TailwindCSS 4 (CSS-first @theme config) |
| GraphQL server | GraphQL Yoga in /api/graphql Next.js route |
| GraphQL client | Apollo Client 4 with 60 s poll for live matches |
| ORM | Drizzle ORM with postgres driver |
| Database | PostgreSQL 16 |
| Flags | flag-icons npm package |
| Fonts | Bebas Neue + Space Grotesk (Google Fonts) |
| Container | Docker multi-stage build, Traefik-compatible |
Data sources
All data is fetched from the openfootball/worldcup.json GitHub repository via raw URLs. The sync script fetches up to seven files per tournament year depending on availability:
| File | Content | Years available |
|---|---|---|
worldcup.json |
Matches, scores (FT/HT/ET/P), goal-scorer events | All (1930–2026) |
worldcup.teams.json |
Team details, FIFA codes, confederation | 2014–2026 |
worldcup.stadiums.json |
Stadium name, city, capacity, coordinates | 2014–2026 |
worldcup.groups.json |
Group compositions | 2014–2026 |
worldcup.standings.json |
Pre-computed group standings | 2014, 2018 |
worldcup.squads.json |
26-man player rosters | 2026 |
worldcup.quali_playoffs.json |
Inter-confederation playoff results | 2026 |
Note: Individual goal-scorer records are only available from openfootball for 1930–1950, 1990, 2006, and 2014–2026. Match scores (used for standings, biggest wins, etc.) are complete for all years.
Database schema
tournaments year PK, host, winner, runner_up, third, fourth,
teams_count, matches_count, total_goals, avg_goals_per_game
teams id, name UNIQUE, iso2, fifa_code, continent, confederation
stadiums id, tournament_year FK, name, city, country_code,
capacity, timezone, coordinates
matches id, tournament_year FK, round, group_name, date, time_local,
stadium_id FK, team1_id FK, team2_id FK,
score_ft_home, score_ft_away,
score_ht_home, score_ht_away,
score_et_home, score_et_away,
score_p_home, score_p_away,
is_quali_playoff
goals id, match_id FK, team_id FK, player_name,
minute, minute_offset, is_penalty, is_own_goal
group_standings tournament_year FK, group_name, team_id FK,
pos, played, won, drawn, lost,
goals_for, goals_against, goal_diff, pts
squads id, tournament_year FK, team_id FK, player_name,
shirt_number, position, date_of_birth
Local development
Prerequisites: Node.js 22+, pnpm 10+, Docker
# 1. Clone and install
git clone <repo-url> worldcup
cd worldcup
pnpm install
# 2. Start the database
docker compose -f docker-compose.dev.yml up -d
# 3. Seed all 23 tournaments
DATABASE_URL="postgres://wc:wc@localhost:5432/worldcup" pnpm sync
# 4. Start the dev server
DATABASE_URL="postgres://wc:wc@localhost:5432/worldcup" pnpm dev
Open http://localhost:3000.
To stop the database: docker compose -f docker-compose.dev.yml down
Environment variables
| Variable | Required | Description |
|---|---|---|
DATABASE_URL |
Yes | PostgreSQL connection string |
DB_PASSWORD |
Production | Password for the wc DB user (used by docker-compose.yml) |
TRAEFIK_ENABLED |
Production | Set to true to activate Traefik router labels |
TRAEFIK_HOST |
Production | Public hostname, e.g. worldcup.example.com |
NETWORK_NAME |
Production | Name of the external Docker network Traefik is attached to |
Copy .env.example to .env and fill in the values before deploying.
Deployment (Coolify + Traefik)
The app is designed for self-hosted deployment via Coolify behind a Traefik reverse proxy.
1. Configure environment
In Coolify's environment variable editor set:
DB_PASSWORD=<strong-random-password>
DATABASE_URL=postgres://wc:<DB_PASSWORD>@db:5432/worldcup
TRAEFIK_ENABLED=true
TRAEFIK_HOST=worldcup.yourdomain.com
NETWORK_NAME=<your-traefik-network-name>
2. Deploy
Coolify builds the Docker image via docker compose up and attaches the container to the Traefik network automatically. TLS certificates are issued by the resolver cert resolver configured in Traefik.
3. Initial data sync
After the first deployment run the sync once manually in Coolify's terminal:
docker compose exec app pnpm sync
4. Scheduled sync (live updates)
In Coolify → your service → Scheduled Tasks, add:
| Field | Value |
|---|---|
| Command | pnpm sync |
| Schedule | */10 * * * * |
| Container | app |
This re-syncs from openfootball every 10 minutes. During the 2026 group stage new match results appear within 10 minutes of the final whistle.
Running the sync manually
# From host (dev)
DATABASE_URL="postgres://wc:wc@localhost:5432/worldcup" pnpm sync
# Inside the app container (production)
docker compose exec app pnpm sync
The sync is fully idempotent — safe to run repeatedly. It upserts every record and recomputes tournament aggregates at the end of each year.
Project structure
worldcup/
├── app/
│ ├── layout.tsx # Root layout: nav, fonts, Apollo provider
│ ├── page.tsx # Home page
│ ├── groups/page.tsx # 2026 group standings
│ ├── stats/page.tsx # All-time statistics
│ ├── history/page.tsx # Tournament history cards
│ ├── search/page.tsx # Full-text search
│ ├── tournaments/[year]/page.tsx # Tournament detail
│ ├── teams/[slug]/page.tsx # Team profile
│ ├── players/[name]/page.tsx # Player profile
│ └── api/graphql/route.ts # GraphQL Yoga endpoint
├── components/
│ ├── apollo-provider.tsx # Apollo Client provider wrapper
│ ├── nav.tsx # Top navigation bar
│ ├── team-flag.tsx # flag-icons wrapper component
│ ├── match-card.tsx # Match result / fixture card
│ └── live-badge.tsx # Pulsing LIVE indicator
├── lib/
│ ├── db/
│ │ ├── schema.ts # Drizzle table definitions
│ │ └── index.ts # DB connection singleton
│ ├── graphql/
│ │ ├── schema.ts # GraphQL SDL
│ │ ├── resolvers/index.ts # All resolvers
│ │ ├── hooks.ts # Apollo v4 useQuery wrapper
│ │ └── client.ts # Apollo Client factory
│ └── iso-codes.ts # Team name → ISO2 country code map
├── scripts/
│ └── sync.ts # Data sync script (all years, idempotent)
├── docker-compose.yml # Production (Traefik + external network)
├── docker-compose.dev.yml # Local dev (DB only, port 5432 exposed)
├── Dockerfile # Multi-stage pnpm build
├── .env.example # Environment variable template
├── next.config.ts # standalone output, serverExternalPackages
├── drizzle.config.ts # Drizzle Kit config
└── tsconfig.json
Architecture notes
Live match detection — A match is considered live when its date equals today and the current time is within 5 minutes before kick-off to 125 minutes after. Time zones are stripped; all times are treated as local tournament time. Apollo's pollInterval: 60_000 re-queries liveMatches every minute.
Apollo Client v4 — This project uses Apollo Client 4 which moved hooks to @apollo/client/react and core utilities to @apollo/client/core. A thin wrapper in lib/graphql/hooks.ts re-exports useQuery typed as Record<string, any> to avoid the v4 TData = {} default breaking all field accesses.
Standalone Docker output — next.config.ts sets output: 'standalone' which produces a self-contained server.js. The scripts/ and lib/ directories are copied separately into the runner stage so pnpm sync works inside the container without needing a full Node/TypeScript toolchain reinstall.
Group standings — Pre-computed standings from openfootball are stored directly. For all other years (and 2026 during the tournament) standings are computed live from match results via a SQL GROUP BY query in the groupStandings resolver.
Total goals — Tournament goal counts are derived from match score totals (score_ft_home + score_ft_away), not from the goals table. This ensures correct numbers for all years, including those where individual scorer records are not available in the openfootball dataset.
GraphQL API
The GraphQL playground is available at /api/graphql in development.
Key queries:
# Live matches right now
{ liveMatches { id date time team1 { name } team2 { name } scoreFt isLive } }
# All-time top scorers
{ topScorers(limit: 10) { playerName goals penalties team { name iso2 } } }
# 2026 group standings
{ groupStandings(year: 2026) { groupName pos team { name iso2 } played won drawn lost goalsFor goalsAgainst pts } }
# Tournament detail
{ tournament(year: 2022) { year host winner totalGoals avgGoalsPerGame } }
# Team stats
{ team(slug: "brazil") { name stats { appearances wins losses titles goalsFor } } }
# Full-text search
{ search(query: "Ronaldo") { teams { name } players { playerName goals } } }
# Hat-tricks in World Cup history
{ hatTricks { playerName goals year round team { name } opponent { name } } }
# Global stats
{ tournamentStats { totalTournaments totalMatches totalGoals avgGoalsPerGame } }