World Cup Dashboard with Docker and CI/CD
Build a live World Cup 2026 dashboard, containerize it, and ship it with CI/CD.
Introduction
30 Second Summary
The World Cup is happening right now, and millions of fans are refreshing apps for live scores. What if you could build your own live dashboard that pulls real tournament data and deploys automatically every time you push code?
In this project, you will build a real-time World Cup 2026 dashboard using Next.js, containerize it with Docker, and set up a CI/CD pipeline that deploys to a public URL on every push to main.
What You'll Build
A production-ready Next.js dashboard that fetches live World Cup 2026 scores, group standings, and match schedules from a free API, containerized with a multi-stage Docker build and deployed automatically via GitHub Actions.
By the end of this project, you'll have:
- A live dashboard showing real World Cup scores, group standings, and match schedules at a public URL.
- A multi-stage Dockerfile that produces a minimal standalone production image under 200MB.
- A GitHub Actions workflow that automatically builds, lints, and deploys your app to Vercel on every push to main.
- Secret Mission: Add AI-powered match insights using AWS Bedrock to generate tactical summaries of completed games.
Are there any prerequisites?
You will need a GitHub account, a free Vercel account, and Docker Desktop installed on your machine. The Secret Mission also requires an AWS account with Bedrock access.
Set Up the Project
To ship a live dashboard, you need three things wired together: the app itself, a place to host it, and an automated pipeline to deploy it.
In this step, you will scaffold a Next.js project, push it to GitHub, and link it to Vercel so your CI/CD pipeline has everything it needs to deploy automatically later.
In this step, get ready to:
- Scaffold a Next.js project with TypeScript, Tailwind CSS, and the App Router.
- Create a GitHub repository and link it to your project.
- Link the project to Vercel and store deployment secrets in GitHub.
Scaffold the Next.js project
Next.js gives you a full-stack React framework with server components and built-in routing. The create-next-app CLI scaffolds a ready-to-run project with all the configuration you need.
- Move to your Desktop by running this command:
cd ~/Desktop
- Create your Next.js project by running this command:
npx create-next-app@latest worldcup-dashboard --typescript --tailwind --eslint --app --src-dir --import-alias "@/*"
What does this command do?
This scaffolds a new Next.js project named worldcup-dashboard with several options pre-configured:
- --typescript enables TypeScript for type safety across the project.
- --tailwind sets up Tailwind CSS for styling.
- --app uses the App Router, which supports server components.
- --src-dir organizes code under a src/ folder.
- --import-alias "@/*" lets you import files with @/ instead of relative paths.
- Navigate into your new project folder by running:
cd worldcup-dashboard
- Confirm the project was created by running:
ls src/app
- You should see files like page.tsx, layout.tsx, and globals.css listed in the output.
Command not found or network error?
- If you see a permission error, try running the command with sudo on macOS or opening your terminal as Administrator on Windows.
- If the download hangs, check your internet connection and retry.
- Make sure you ran cd ~/Desktop first so the folder lands on your Desktop.
Why isn't create-next-app working for me?
Connect GitHub and Vercel
Your project needs a remote Git repository so the CI/CD pipeline can trigger on every push. You also need to link it to Vercel so the pipeline knows where to deploy.
- Open GitHub in your browser.
- Create a new repository named worldcup-dashboard.
- Leave the repository empty (do not add a README, .gitignore, or license).
- Click Create repository.
Why an empty repo?
The create-next-app command already initialized Git and made an initial commit in your project folder. An empty remote avoids merge conflicts when you push for the first time.
- Back in your terminal, link your local project to the GitHub repository by running:
git remote add origin [[YOUR_REPO_URL="https://github.com/YOUR_USERNAME/worldcup-dashboard.git"]]
- Push your code to GitHub by running:
git push -u origin main
- You should see output confirming that the main branch was pushed successfully.
Push rejected or authentication failed?
- If you see an authentication error, make sure you are signed in to GitHub via the CLI. Run gh auth login or set up an SSH key.
- If the push is rejected because the remote is not empty, delete the repo on GitHub and recreate it without any files.
Help me push my code to GitHub.
Now link the project to Vercel so the platform knows about your app.
- Link your project to Vercel by running:
vercel link
What happens during vercel link?
The CLI will ask you to select your scope (your Vercel account), then whether to link to an existing project or create a new one. Since this is a new project, answer N to link an existing project, then accept the default name worldcup-dashboard.
The CLI detects Next.js and suggests default build settings. Answer N to customizing settings to accept the defaults. After linking, a .vercel directory is created in your project with a project.json file containing your orgId and projectId.
- View your project IDs by running:
cat .vercel/project.json
- You should see JSON output containing projectId and orgId values. Copy both values somewhere safe. You will need them in a moment.
Next, generate a Vercel access token so GitHub Actions can deploy on your behalf.
- Open the Vercel Account Tokens page at vercel.com/account/tokens.
- Click Create.
- Enter GitHub Actions deploy as the token name.
- Select Full Account for the scope.
- Click Create Token.
- Copy the token immediately. It is only shown once.
Why a personal access token?
GitHub Actions cannot log in interactively to Vercel. The personal access token lets the CI/CD workflow authenticate with the Vercel API to pull environment info, build, and deploy without human interaction.
Finally, store all three secrets in your GitHub repository so the CI/CD workflow can use them.
- In your GitHub repository, click Settings in the top navigation bar.
- In the left sidebar, expand Secrets and variables, then click Actions.
- Click New repository secret.
- Add the first secret with name VERCEL_TOKEN and paste the token you just copied as the value.
- Click Add secret.
- Repeat for a second secret with name VERCEL_ORG_ID and the orgId value from .vercel/project.json.
- Repeat for a third secret with name VERCEL_PROJECT_ID and the projectId value from .vercel/project.json.
- Confirm all three secrets appear in the Repository secrets list on the Actions secrets page.
Don't see the Settings tab?
- The Settings tab only appears if you are the repository owner. Double-check you are on the correct repository page.
- If you created the repo under an organization, you may need admin permissions to add secrets.
Help me add secrets to my GitHub repository.
Your project is scaffolded, pushed to GitHub, and linked to Vercel with all the secrets in place. Next up, you will build the live dashboard that pulls real World Cup data from an API.
Build the Live Dashboard
Your project is scaffolded and linked to GitHub and Vercel. Now it's time to build something people can actually see.
In this step, you'll connect your dashboard to a free, live World Cup 2026 API and display real match data. You'll build typed data fetching, multiple pages with the Next.js App Router, and reusable UI components.
In this step, get ready to:
- Create a typed data fetching layer that pulls live World Cup scores from a public API.
- Build dashboard pages showing live matches, group standings, and a full match schedule.
- Add reusable components, navigation, loading skeletons, and error boundaries.
Create the data fetching layer
Before building any UI, you need a clean way to pull data from the World Cup API. The worldcup26.ir API provides live scores, group standings, team info, and stadium data with no authentication required.
Why a separate API layer?
Putting all your fetch logic in one file means every page imports from the same place. If the API changes its URL or response shape, you fix it in one spot instead of hunting through multiple page files.
- Create the src/lib folder and a new file called api.ts inside it by running:
mkdir -p src/lib && touch src/lib/api.ts
- Open src/lib/api.ts in Cursor and paste the following TypeScript interfaces and fetch functions:
const API_BASE = "https://worldcup26.ir";
export interface Team {
id: string;
name_en: string;
name_fa: string;
fifa_code: string;
groups: string;
flag: string;
}
export interface Match {
id: string;
home_team_id: string;
away_team_id: string;
home_score: string;
away_score: string;
home_scorers: string;
away_scorers: string;
group: string;
matchday: string;
local_date: string;
stadium_id: string;
finished: string;
time_elapsed: string;
type: string;
home_team_name_en?: string;
away_team_name_en?: string;
home_team_label?: string;
away_team_label?: string;
}
export interface GroupStanding {
team_id: string;
pts: string;
gf: string;
ga: string;
}
export interface Group {
group: string;
teams: GroupStanding[];
}
export interface Stadium {
id: string;
name_en: string;
name_fa: string;
fifa_name: string;
city_en: string;
country_en: string;
capacity: number;
}
async function fetchAPI<T>(endpoint: string): Promise<T> {
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), 10000);
try {
const response = await fetch(`${API_BASE}${endpoint}`, {
signal: controller.signal,
next: { revalidate: 60 },
});
if (!response.ok) {
throw new Error(`API error: ${response.status} ${response.statusText}`);
}
return response.json();
} catch (error) {
if (error instanceof Error && error.name === "AbortError") {
throw new Error("Request timed out after 10 seconds");
}
throw error;
} finally {
clearTimeout(timeout);
}
}
export async function getMatches(): Promise<Match[]> {
const data = await fetchAPI<{ games: Match[] }>("/get/games");
return data.games;
}
export async function getMatch(id: string): Promise<Match> {
const data = await fetchAPI<{ game: Match }>(`/get/game/${id}`);
return data.game;
}
export async function getGroups(): Promise<Group[]> {
const data = await fetchAPI<Group[]>("/get/groups");
return data;
}
export async function getTeams(): Promise<Team[]> {
const data = await fetchAPI<Team[]>("/get/teams");
return data;
}
export async function getStadiums(): Promise<Stadium[]> {
const data = await fetchAPI<Stadium[]>("/get/stadiums");
return data;
}
What does this code do?
- The TypeScript interfaces (Team, Match, Group, Stadium) define the exact shape of data the API returns, so TypeScript catches errors before they reach the browser.
- The fetchAPI helper wraps every request with a 10-second timeout using AbortController, HTTP status checking, and next: { revalidate: 60 } which tells Next.js to cache responses for 60 seconds before refetching.
- The exported functions (getMatches, getGroups, etc.) are the public API your pages will import. Each one calls a specific endpoint and unwraps the response.
- Save src/lib/api.ts.
- You won't see a visual change yet. The pages you build next will import these functions.
Seeing a red underline on the next property?
If TypeScript complains about next: { revalidate: 60 } in the fetch options, make sure your tsconfig.json has "moduleResolution": "bundler" (the default from create-next-app). This is a Next.js-specific extension to the fetch API that TypeScript recognizes through the project's type definitions.
help me fix a TypeScript error on the Next.js fetch revalidate option
✔️ Awesome, I've got everything!
Great. Save the file and move on to building the pages.
ⓧ I'd like to double check the full code
const API_BASE = "https://worldcup26.ir";
export interface Team {
id: string;
name_en: string;
name_fa: string;
fifa_code: string;
groups: string;
flag: string;
}
export interface Match {
id: string;
home_team_id: string;
away_team_id: string;
home_score: string;
away_score: string;
home_scorers: string;
away_scorers: string;
group: string;
matchday: string;
local_date: string;
stadium_id: string;
finished: string;
time_elapsed: string;
type: string;
home_team_name_en?: string;
away_team_name_en?: string;
home_team_label?: string;
away_team_label?: string;
}
export interface GroupStanding {
team_id: string;
pts: string;
gf: string;
ga: string;
}
export interface Group {
group: string;
teams: GroupStanding[];
}
export interface Stadium {
id: string;
name_en: string;
name_fa: string;
fifa_name: string;
city_en: string;
country_en: string;
capacity: number;
}
async function fetchAPI<T>(endpoint: string): Promise<T> {
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), 10000);
try {
const response = await fetch(`${API_BASE}${endpoint}`, {
signal: controller.signal,
next: { revalidate: 60 },
});
if (!response.ok) {
throw new Error(`API error: ${response.status} ${response.statusText}`);
}
return response.json();
} catch (error) {
if (error instanceof Error && error.name === "AbortError") {
throw new Error("Request timed out after 10 seconds");
}
throw error;
} finally {
clearTimeout(timeout);
}
}
export async function getMatches(): Promise<Match[]> {
const data = await fetchAPI<{ games: Match[] }>("/get/games");
return data.games;
}
export async function getMatch(id: string): Promise<Match> {
const data = await fetchAPI<{ game: Match }>(`/get/game/${id}`);
return data.game;
}
export async function getGroups(): Promise<Group[]> {
const data = await fetchAPI<Group[]>("/get/groups");
return data;
}
export async function getTeams(): Promise<Team[]> {
const data = await fetchAPI<Team[]>("/get/teams");
return data;
}
export async function getStadiums(): Promise<Stadium[]> {
const data = await fetchAPI<Stadium[]>("/get/stadiums");
return data;
}
Build the dashboard pages
With the data layer ready, you can create the pages that display live match data. Next.js App Router uses file-system routing. Each folder under src/app/ becomes a URL path, and a page.tsx file inside it defines what renders at that route.
- Create the loading skeleton by replacing the contents of src/app/loading.tsx with:
export default function Loading() {
return (
<main className="min-h-screen bg-gray-950 text-white p-6">
<div className="animate-pulse">
<div className="h-10 bg-gray-800 rounded w-96 mx-auto mb-8" />
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{Array.from({ length: 6 }).map((_, i) => (
<div key={i} className="h-32 bg-gray-800 rounded-lg" />
))}
</div>
</div>
</main>
);
}
What is a loading skeleton?
Next.js automatically shows this component while a page's async data is still fetching. The animate-pulse class from Tailwind CSS creates a subtle pulsing animation on the gray placeholder blocks. This gives users instant feedback that content is on its way.
- Save src/app/loading.tsx.
- Create the error boundary by creating a new file at src/app/error.tsx and pasting:
"use client";
export default function Error({
error,
reset,
}: {
error: Error & { digest?: string };
reset: () => void;
}) {
return (
<main className="min-h-screen bg-gray-950 text-white p-6 flex flex-col items-center justify-center">
<h2 className="text-2xl font-bold text-red-400 mb-4">
Something went wrong
</h2>
<p className="text-gray-400 mb-6">{error.message}</p>
<button
onClick={reset}
className="px-4 py-2 bg-blue-600 rounded hover:bg-blue-700 transition"
>
Try again
</button>
</main>
);
}
Why does this file need "use client"?
Error boundaries in Next.js must be client components because they use React's reset mechanism to re-render the page. The "use client" directive at the top tells Next.js this component runs in the browser, not on the server.
- Save src/app/error.tsx.
- Now build the home page. Replace the contents of src/app/page.tsx with:
import { getMatches } from "@/lib/api";
import { MatchCard } from "@/components/MatchCard";
export default async function Home() {
const matches = await getMatches();
const liveMatches = matches.filter(
(m) => m.finished === "FALSE" && m.time_elapsed !== "notstarted"
);
const recentMatches = matches
.filter((m) => m.finished === "TRUE")
.slice(-6);
const upcomingMatches = matches
.filter((m) => m.finished === "FALSE" && m.time_elapsed === "notstarted")
.slice(0, 6);
return (
<main className="min-h-screen bg-gray-950 text-white p-6">
<h1 className="text-4xl font-bold text-center mb-8">
FIFA World Cup 2026 Dashboard
</h1>
{liveMatches.length > 0 && (
<section className="mb-10">
<h2 className="text-2xl font-semibold text-red-400 mb-4">
Live Now
</h2>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{liveMatches.map((match) => (
<MatchCard key={match.id} match={match} />
))}
</div>
</section>
)}
<section className="mb-10">
<h2 className="text-2xl font-semibold text-green-400 mb-4">
Recent Results
</h2>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{recentMatches.map((match) => (
<MatchCard key={match.id} match={match} />
))}
</div>
</section>
<section>
<h2 className="text-2xl font-semibold text-blue-400 mb-4">
Upcoming Matches
</h2>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{upcomingMatches.map((match) => (
<MatchCard key={match.id} match={match} />
))}
</div>
</section>
</main>
);
}
What does this page do?
- This is an async server component. It fetches data directly on the server with no client-side JavaScript needed.
- It splits the matches into three categories using the finished and time_elapsed fields: live (in progress), recent (completed), and upcoming (not started).
- It imports MatchCard which you'll create in the next substep.
- Save src/app/page.tsx.
- Create the groups page by making a new folder and file at src/app/groups/page.tsx:
mkdir -p src/app/groups && touch src/app/groups/page.tsx
- Open src/app/groups/page.tsx and paste:
import { getGroups, getTeams } from "@/lib/api";
import { GroupTable } from "@/components/GroupTable";
export default async function GroupsPage() {
const [groups, teams] = await Promise.all([getGroups(), getTeams()]);
return (
<main className="min-h-screen bg-gray-950 text-white p-6">
<h1 className="text-3xl font-bold text-center mb-8">Group Standings</h1>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{groups.map((group) => (
<GroupTable key={group.group} group={group} teams={teams} />
))}
</div>
</main>
);
}
What is Promise.all doing here?
The groups page needs both group standings AND team names. Promise.all fetches both datasets in parallel instead of one after the other, cutting the page's load time roughly in half.
- Save src/app/groups/page.tsx.
- Create the full match schedule page at src/app/matches/page.tsx:
mkdir -p src/app/matches && touch src/app/matches/page.tsx
- Open src/app/matches/page.tsx and paste:
import { getMatches } from "@/lib/api";
import { MatchCard } from "@/components/MatchCard";
export default async function MatchesPage() {
const matches = await getMatches();
const stages = [
{ label: "Group Stage", types: ["group"] },
{ label: "Round of 32", types: ["r32"] },
{ label: "Round of 16", types: ["r16"] },
{ label: "Quarter-finals", types: ["qf"] },
{ label: "Semi-finals", types: ["sf"] },
{ label: "Third Place", types: ["third"] },
{ label: "Final", types: ["final"] },
];
return (
<main className="min-h-screen bg-gray-950 text-white p-6">
<h1 className="text-3xl font-bold text-center mb-8">All Matches</h1>
{stages.map((stage) => {
const stageMatches = matches.filter((m) =>
stage.types.includes(m.type)
);
if (stageMatches.length === 0) return null;
return (
<section key={stage.label} className="mb-10">
<h2 className="text-2xl font-semibold text-purple-400 mb-4">
{stage.label}
</h2>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{stageMatches.map((match) => (
<MatchCard key={match.id} match={match} />
))}
</div>
</section>
);
})}
</main>
);
}
How does this page organize 104 matches?
The stages array defines tournament phases in order. The page filters all matches by their type field (group, r32, r16, etc.) and renders each stage as a separate section. Stages with no matches yet return null and don't render at all.
- Save src/app/matches/page.tsx.
- Your pages import MatchCard and GroupTable components that don't exist yet. You'll create them next.
Add components and navigation
Your pages reference three components: MatchCard for individual match results, GroupTable for standings, and Nav for site-wide navigation. Time to build them.
- Create the components folder and files by running:
mkdir -p src/components && touch src/components/MatchCard.tsx src/components/GroupTable.tsx src/components/Nav.tsx
- Open src/components/MatchCard.tsx and paste:
import type { Match } from "@/lib/api";
function getStatusBadge(match: Match) {
if (match.finished === "TRUE") {
return <span className="text-xs px-2 py-1 bg-gray-700 rounded">FT</span>;
}
if (match.time_elapsed !== "notstarted") {
return (
<span className="text-xs px-2 py-1 bg-red-600 rounded animate-pulse">
{match.time_elapsed}
</span>
);
}
return (
<span className="text-xs px-2 py-1 bg-gray-700 rounded">
{match.local_date}
</span>
);
}
export function MatchCard({ match }: { match: Match }) {
const homeName = match.home_team_name_en || match.home_team_label || "TBD";
const awayName = match.away_team_name_en || match.away_team_label || "TBD";
return (
<div className="bg-gray-900 border border-gray-800 rounded-lg p-4">
<div className="flex justify-between items-center mb-3">
<span className="text-xs text-gray-400 uppercase">
{match.type === "group" ? `Group ${match.group}` : match.group}
</span>
{getStatusBadge(match)}
</div>
<div className="flex items-center justify-between">
<span className="text-sm font-medium w-28 truncate">{homeName}</span>
<span className="text-lg font-bold mx-4">
{match.home_score} - {match.away_score}
</span>
<span className="text-sm font-medium w-28 truncate text-right">
{awayName}
</span>
</div>
</div>
);
}
How do the status indicators work?
- The getStatusBadge function checks two fields: finished and time_elapsed.
- If finished === "TRUE", it shows a static "FT" (full time) badge.
- If the match is in progress (time_elapsed is not "notstarted" but not finished), it shows a pulsing red badge with the current minute.
- Otherwise it displays the scheduled date for upcoming matches.
- Save src/components/MatchCard.tsx.
✔️ Awesome, I've got everything!
Great. Move on to the GroupTable component.
ⓧ I'd like to double check the full code
import type { Match } from "@/lib/api";
function getStatusBadge(match: Match) {
if (match.finished === "TRUE") {
return <span className="text-xs px-2 py-1 bg-gray-700 rounded">FT</span>;
}
if (match.time_elapsed !== "notstarted") {
return (
<span className="text-xs px-2 py-1 bg-red-600 rounded animate-pulse">
{match.time_elapsed}
</span>
);
}
return (
<span className="text-xs px-2 py-1 bg-gray-700 rounded">
{match.local_date}
</span>
);
}
export function MatchCard({ match }: { match: Match }) {
const homeName = match.home_team_name_en || match.home_team_label || "TBD";
const awayName = match.away_team_name_en || match.away_team_label || "TBD";
return (
<div className="bg-gray-900 border border-gray-800 rounded-lg p-4">
<div className="flex justify-between items-center mb-3">
<span className="text-xs text-gray-400 uppercase">
{match.type === "group" ? `Group ${match.group}` : match.group}
</span>
{getStatusBadge(match)}
</div>
<div className="flex items-center justify-between">
<span className="text-sm font-medium w-28 truncate">{homeName}</span>
<span className="text-lg font-bold mx-4">
{match.home_score} - {match.away_score}
</span>
<span className="text-sm font-medium w-28 truncate text-right">
{awayName}
</span>
</div>
</div>
);
}
- Open src/components/GroupTable.tsx and paste:
import type { Group, Team } from "@/lib/api";
export function GroupTable({
group,
teams,
}: {
group: Group;
teams: Team[];
}) {
const teamMap = new Map(teams.map((t) => [t.id, t]));
return (
<div className="bg-gray-900 border border-gray-800 rounded-lg p-4">
<h3 className="text-lg font-semibold mb-3 text-blue-400">
Group {group.group}
</h3>
<table className="w-full text-sm">
<thead>
<tr className="text-gray-400 text-xs">
<th className="text-left pb-2">Team</th>
<th className="text-center pb-2">Pts</th>
<th className="text-center pb-2">GF</th>
<th className="text-center pb-2">GA</th>
</tr>
</thead>
<tbody>
{group.teams.map((standing) => {
const team = teamMap.get(standing.team_id);
return (
<tr key={standing.team_id} className="border-t border-gray-800">
<td className="py-2">{team?.name_en || "Unknown"}</td>
<td className="text-center font-bold">{standing.pts}</td>
<td className="text-center text-gray-400">{standing.gf}</td>
<td className="text-center text-gray-400">{standing.ga}</td>
</tr>
);
})}
</tbody>
</table>
</div>
);
}
What does the teamMap do?
The API returns group standings with only a team_id. To display actual team names, the component creates a Map from the full teams list so it can look up each team's name_en instantly by ID.
- Save src/components/GroupTable.tsx.
✔️ Awesome, I've got everything!
Great. One more component to go.
ⓧ I'd like to double check the full code
import type { Group, Team } from "@/lib/api";
export function GroupTable({
group,
teams,
}: {
group: Group;
teams: Team[];
}) {
const teamMap = new Map(teams.map((t) => [t.id, t]));
return (
<div className="bg-gray-900 border border-gray-800 rounded-lg p-4">
<h3 className="text-lg font-semibold mb-3 text-blue-400">
Group {group.group}
</h3>
<table className="w-full text-sm">
<thead>
<tr className="text-gray-400 text-xs">
<th className="text-left pb-2">Team</th>
<th className="text-center pb-2">Pts</th>
<th className="text-center pb-2">GF</th>
<th className="text-center pb-2">GA</th>
</tr>
</thead>
<tbody>
{group.teams.map((standing) => {
const team = teamMap.get(standing.team_id);
return (
<tr key={standing.team_id} className="border-t border-gray-800">
<td className="py-2">{team?.name_en || "Unknown"}</td>
<td className="text-center font-bold">{standing.pts}</td>
<td className="text-center text-gray-400">{standing.gf}</td>
<td className="text-center text-gray-400">{standing.ga}</td>
</tr>
);
})}
</tbody>
</table>
</div>
);
}
- Open src/components/Nav.tsx and paste:
import Link from "next/link";
export function Nav() {
return (
<nav className="bg-gray-900 border-b border-gray-800 px-6 py-4">
<div className="max-w-7xl mx-auto flex items-center gap-6">
<Link href="/" className="text-xl font-bold text-white">
WC2026
</Link>
<Link
href="/groups"
className="text-gray-300 hover:text-white transition"
>
Groups
</Link>
<Link
href="/matches"
className="text-gray-300 hover:text-white transition"
>
All Matches
</Link>
</div>
</nav>
);
}
What does this navigation do?
The Nav component uses Next.js Link components for client-side navigation between routes. Clicking a link loads the new page without a full browser refresh, keeping the experience fast.
- Save src/components/Nav.tsx.
The last piece is wiring the Nav into your layout so it appears on every page.
- Open src/app/layout.tsx and replace its entire contents with:
import type { Metadata } from "next";
import { Inter } from "next/font/google";
import "./globals.css";
import { Nav } from "@/components/Nav";
const inter = Inter({ subsets: ["latin"] });
export const metadata: Metadata = {
title: "World Cup 2026 Dashboard",
description:
"Live scores, group standings, and match schedule for the 2026 FIFA World Cup",
};
export default function RootLayout({
children,
}: Readonly<{
children: React.ReactNode;
}>) {
return (
<html lang="en">
<body className={`${inter.className} bg-gray-950`}>
<Nav />
{children}
</body>
</html>
);
}
What changed in the layout?
- You imported and rendered the Nav component above {children}. Since the root layout wraps every page, the navigation bar now appears everywhere.
- You added custom metadata (title and description) which improves SEO and shows in the browser tab.
- The Inter font loads from Google Fonts and applies to the entire app body.
- Save src/app/layout.tsx.
All files are in place. Time to see your dashboard in action.
- Start the development server by running:
npm run dev
- Open http://localhost:3000 in your browser.
- You should see the navigation bar at the top, followed by live/recent/upcoming match cards with real World Cup scores.
- Click Groups in the nav. You should see all 12 group standing tables with team names, points, goals for, and goals against.
- Click All Matches in the nav. You should see the full 104-match schedule organized by tournament stage.
Not seeing any match data?
- Check your terminal for error messages. A common cause is a network issue reaching worldcup26.ir. Make sure you have an internet connection.
- If you see a module-not-found error for @/components/MatchCard or @/components/GroupTable, double-check that the files exist at src/components/MatchCard.tsx and src/components/GroupTable.tsx. The @/ alias maps to src/ per your tsconfig.json.
- If you see the error boundary ("Something went wrong"), the API might be temporarily down. Click Try again to retry the request.
help me debug why my World Cup dashboard is not displaying match data
✔️ Awesome, I've got everything!
Great. Double check you've saved all your files.
ⓧ I'd like to double check the full code
Here is the complete code for src/app/layout.tsx:
import type { Metadata } from "next";
import { Inter } from "next/font/google";
import "./globals.css";
import { Nav } from "@/components/Nav";
const inter = Inter({ subsets: ["latin"] });
export const metadata: Metadata = {
title: "World Cup 2026 Dashboard",
description:
"Live scores, group standings, and match schedule for the 2026 FIFA World Cup",
};
export default function RootLayout({
children,
}: Readonly<{
children: React.ReactNode;
}>) {
return (
<html lang="en">
<body className={`${inter.className} bg-gray-950`}>
<Nav />
{children}
</body>
</html>
);
}
And src/app/page.tsx:
import { getMatches } from "@/lib/api";
import { MatchCard } from "@/components/MatchCard";
export default async function Home() {
const matches = await getMatches();
const liveMatches = matches.filter(
(m) => m.finished === "FALSE" && m.time_elapsed !== "notstarted"
);
const recentMatches = matches
.filter((m) => m.finished === "TRUE")
.slice(-6);
const upcomingMatches = matches
.filter((m) => m.finished === "FALSE" && m.time_elapsed === "notstarted")
.slice(0, 6);
return (
<main className="min-h-screen bg-gray-950 text-white p-6">
<h1 className="text-4xl font-bold text-center mb-8">
FIFA World Cup 2026 Dashboard
</h1>
{liveMatches.length > 0 && (
<section className="mb-10">
<h2 className="text-2xl font-semibold text-red-400 mb-4">
Live Now
</h2>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{liveMatches.map((match) => (
<MatchCard key={match.id} match={match} />
))}
</div>
</section>
)}
<section className="mb-10">
<h2 className="text-2xl font-semibold text-green-400 mb-4">
Recent Results
</h2>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{recentMatches.map((match) => (
<MatchCard key={match.id} match={match} />
))}
</div>
</section>
<section>
<h2 className="text-2xl font-semibold text-blue-400 mb-4">
Upcoming Matches
</h2>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{upcomingMatches.map((match) => (
<MatchCard key={match.id} match={match} />
))}
</div>
</section>
</main>
);
}
Your dashboard is pulling real, live tournament data and displaying it beautifully. Next up, you'll containerize this app with Docker so it can run anywhere.
Dockerize with a Multi-Stage Build
Your dashboard is live locally, fetching real World Cup data and displaying scores across multiple routes. Now you need to package it so it runs the same way everywhere.
A Docker container wraps your app with everything it needs to run. In this step, you will create a production-optimized container image using a multi-stage build that keeps the final image small and secure.
In this step, get ready to:
- Configure Next.js standalone output for minimal production builds.
- Create a multi-stage Dockerfile optimized for production.
- Build and run your containerized dashboard locally.
Configure standalone output and create .dockerignore
Next.js has a special output mode called standalone that bundles only the files your app actually needs to run in production. Without it, you would need to copy the entire node_modules folder (500MB+) into your Docker image.
- In Cursor, open next.config.ts from the root of your worldcup-dashboard project.
- Find this line:
const nextConfig: NextConfig = {};
- Replace it with the following to enable standalone output:
const nextConfig: NextConfig = {
output: "standalone",
};
What does standalone output do?
The output: "standalone" setting tells Next.js to trace which files are actually imported and produce a self-contained .next/standalone folder with a minimal server.js entrypoint.
This folder can run your entire app with just node server.js and no node_modules directory needed.
- Save next.config.ts.
Next, you need a .dockerignore file to keep large, unnecessary files out of the Docker build context. This speeds up the build and prevents secrets from leaking into the image.
- Create a new file called .dockerignore in the root of your worldcup-dashboard folder and add this content:
node_modules
.next
.git
.env*.local
.vercel
What does .dockerignore do?
When Docker builds an image, it sends the entire project directory as the "build context" to the Docker daemon. The .dockerignore file excludes files from this context, keeping the build fast and the image clean.
- node_modules is excluded because Docker will install fresh dependencies inside the image.
- .next is excluded because Docker will run npm run build from scratch.
- .env*.local is excluded to prevent secrets from being baked into the image.
- Save .dockerignore.
- You should see .dockerignore appear in the Explorer sidebar alongside next.config.ts.
Don't see the file in the sidebar?
Files starting with a dot are hidden by default on some systems. In Cursor's Explorer, check that you created the file at the project root (same level as package.json), not inside src/.
Help me find my .dockerignore file in Cursor
Write the multi-stage Dockerfile
A multi-stage Dockerfile uses separate build phases to keep the final image minimal. Each stage starts from a fresh base image and only the files you explicitly copy forward end up in the production image.
- Create a new file called Dockerfile (no extension) in the root of your worldcup-dashboard folder and add the following:
# Stage 1: Install production dependencies only
FROM node:22-alpine AS deps
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci --omit=dev
# Stage 2: Build the Next.js application
FROM node:22-alpine AS builder
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci
COPY . .
RUN npm run build
# Stage 3: Production runner with minimal footprint
FROM node:22-alpine AS runner
WORKDIR /app
ENV NODE_ENV=production
ENV HOSTNAME=0.0.0.0
ENV PORT=3000
RUN addgroup --system --gid 1001 nodejs
RUN adduser --system --uid 1001 nextjs
COPY --from=builder /app/public ./public
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
USER nextjs
EXPOSE 3000
CMD ["node", "server.js"]
What do the three stages do?
- deps installs only production dependencies. This stage is available if you need a separate prod-only layer later.
- builder installs all dependencies (including dev), copies your source code, and runs npm run build to produce the standalone output in .next/standalone.
- runner starts from a clean Alpine image and copies only three things: public/ (static assets), .next/standalone (the server), and .next/static (compiled CSS/JS bundles).
The nextjs user ensures the container runs as a non-root user, which is a security best practice for production.
Why multi-stage instead of a single Dockerfile?
A single-stage build would include the full node_modules directory, source code, and build tools in the final image. That typically weighs 500MB or more.
By splitting into stages, the final runner image contains only the compiled output and the Node.js runtime. Your image should come in under 200MB.
- Save Dockerfile.
- Confirm the file appears in the Explorer sidebar at the project root, alongside .dockerignore.
Dockerfile not recognized by Cursor?
Make sure the file is named exactly Dockerfile with a capital D and no file extension. If you accidentally named it Dockerfile.txt or dockerfile, Docker will not find it.
Help me fix my Dockerfile naming
✔️ Awesome, I've got everything!
Great. Double check you have saved both .dockerignore and Dockerfile at the project root.
ⓧ I'd like to double check the full code
Here is the complete next.config.ts:
import type { NextConfig } from "next";
const nextConfig: NextConfig = {
output: "standalone",
};
export default nextConfig;
Here is the complete .dockerignore:
node_modules
.next
.git
.env*.local
.vercel
Here is the complete Dockerfile:
FROM node:22-alpine AS deps
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci --omit=dev
FROM node:22-alpine AS builder
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci
COPY . .
RUN npm run build
FROM node:22-alpine AS runner
WORKDIR /app
ENV NODE_ENV=production
ENV HOSTNAME=0.0.0.0
ENV PORT=3000
RUN addgroup --system --gid 1001 nodejs
RUN adduser --system --uid 1001 nextjs
COPY --from=builder /app/public ./public
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
USER nextjs
EXPOSE 3000
CMD ["node", "server.js"]
Build and run the container locally
With the Dockerfile ready, you can now build a production image and spin it up to verify everything works inside the container.
- Make sure Docker Desktop is running. You should see the Docker icon in your system tray (macOS menu bar or Windows taskbar).
- Open the integrated terminal in Cursor by pressing Ctrl+` (both macOS and Windows).
- Build the Docker image by running this command:
docker build -t worldcup-dashboard .
What does this command do?
- docker build tells Docker to read your Dockerfile and execute each stage.
- -t worldcup-dashboard tags (names) the resulting image so you can reference it easily.
- . sets the build context to the current directory (your project root).
The build takes 1-3 minutes as it installs dependencies and compiles your Next.js app inside the container. You will see output from each stage printing in the terminal.
- Once the build finishes successfully, check the image size by running:
docker images worldcup-dashboard
You should see a single row showing worldcup-dashboard with a size under 200MB.
- Start the container by running:
docker run -p 3000:3000 worldcup-dashboard
What does -p 3000:3000 mean?
The -p 3000:3000 flag maps port 3000 on your machine to port 3000 inside the container. This lets you access the containerized app at http://localhost:3000 just like when you ran npm run dev.
- Open your browser and navigate to http://localhost:3000.
- You should see the full World Cup 2026 dashboard with live match data, working navigation between Home, Groups, and All Matches.
Seeing "port already in use" or a blank page?
- If port 3000 is already in use, stop your npm run dev process first (Ctrl+C in that terminal), then re-run the docker run command.
- If the build failed, check for typos in your Dockerfile. The most common error is a missing output: "standalone" in next.config.ts. Without it, the .next/standalone folder will not exist.
- If the container starts but shows no data, the API at worldcup26.ir may be temporarily unreachable from inside the container. Wait a moment and refresh.
Help me debug my Docker container not loading
- When you are done verifying, press Ctrl+C in the terminal to stop the container.
Your dashboard is now containerized and proven to work in an isolated environment. Next up, you will automate building and deploying this project with a GitHub Actions CI/CD pipeline.
Set Up the CI/CD Pipeline
Your dashboard is built, containerized, and ready to serve live World Cup data. The missing piece is automation. Right now, deploying requires manual commands every time you make a change.
A CI/CD pipeline eliminates that manual work. Every push to main will automatically lint your code, verify the Docker build, and deploy to Vercel. In this step, you will create a GitHub Actions workflow that handles the entire process.
In this step, get ready to:
- Create a GitHub Actions workflow that deploys to Vercel on every push.
- Add a Docker build verification gate to the pipeline.
- Write a portfolio-grade README documenting your project.
Create the GitHub Actions workflow
GitHub Actions discovers workflow files stored in the .github/workflows directory of your repository. You need to create this nested directory structure first, then add the workflow YAML file inside it.
- Create the workflow directory by running these commands in your terminal:
mkdir -p .github/workflows
- Create a new file called deploy.yml inside .github/workflows/.
- Paste the following workflow configuration into deploy.yml:
name: Deploy to Vercel
env:
VERCEL_ORG_ID: ${{ secrets.VERCEL_ORG_ID }}
VERCEL_PROJECT_ID: ${{ secrets.VERCEL_PROJECT_ID }}
on:
push:
branches:
- main
jobs:
deploy:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: "22"
- name: Install dependencies
run: npm ci
- name: Lint
run: npm run lint
- name: Verify Docker build
run: docker build -t worldcup-dashboard .
- name: Install Vercel CLI
run: npm install --global vercel@latest
- name: Pull Vercel Environment Information
run: vercel pull --yes --environment=production --token=${{ secrets.VERCEL_TOKEN }}
- name: Build Project Artifacts
run: vercel build --prod --token=${{ secrets.VERCEL_TOKEN }}
- name: Deploy to Vercel
run: vercel deploy --prebuilt --prod --token=${{ secrets.VERCEL_TOKEN }}
What does this workflow do?
This workflow runs every time you push to the main branch. It performs several checks before deploying:
- npm ci installs your exact locked dependencies for reproducible builds.
- npm run lint runs ESLint to catch code quality issues before they reach production.
- docker build -t worldcup-dashboard . verifies your Dockerfile still produces a valid image (more on this next).
- vercel pull fetches your project's environment configuration from Vercel.
- vercel build --prod builds the production artifacts locally in CI.
- vercel deploy --prebuilt --prod uploads only the pre-built artifacts, skipping a redundant build on Vercel's servers.
The env block at the top pulls VERCEL_ORG_ID and VERCEL_PROJECT_ID from the GitHub secrets you set up in Step 1.
- Save .github/workflows/deploy.yml.
- Confirm the file exists by running:
ls .github/workflows/
- You should see deploy.yml in the output.
Don't see the file?
- Make sure you created both the .github and workflows directories. The -p flag in mkdir -p creates both at once.
- Check you are in the worldcup-dashboard project root, not inside a subdirectory.
Help me figure out why my workflow file isn't showing up.
Understand the Docker build gate
Notice the Verify Docker build step in your workflow. This is a CI gate that catches Dockerfile problems before deployment.
Why verify Docker in CI?
Your Dockerfile is a production-ready artifact that proves your app can run anywhere (ECS, Kubernetes, any cloud). If someone changes a dependency or file path and breaks the Docker build, you want to know immediately.
By placing the Docker step before the Vercel deploy steps, any Docker failure blocks the entire deployment. Broken containers never slip through to production.
Create a portfolio-grade README
A professional README is what separates a personal experiment from a portfolio piece. Reviewers and hiring managers look at your README before they look at your code.
- Create a file called .env.local.example in your project root to document environment variables:
# AWS Bedrock (Secret Mission only)
AWS_ACCESS_KEY_ID=your-access-key-here
AWS_SECRET_ACCESS_KEY=your-secret-key-here
AWS_REGION=us-east-1
What is this file for?
The .env.local.example file shows developers what environment variables your project supports without exposing real credentials. Contributors copy it to .env.local and fill in their own values.
- Now replace the default README.md with a portfolio-grade version. Open README.md and replace its entire contents with:
# World Cup 2026 Dashboard
Live scores, group standings, and match schedule for the 2026 FIFA World Cup.
## Architecture
```
worldcup26.ir API → Next.js Server Components → Vercel Edge Network → User
```
Data flows from the free World Cup 2026 API through Next.js server components that fetch and cache responses every 60 seconds. The app deploys to Vercel via a GitHub Actions CI/CD pipeline that also validates the Docker build on every push.
## Tech Stack
- **Framework**: Next.js 16 (App Router, Server Components)
- **Language**: TypeScript
- **Styling**: Tailwind CSS
- **Containerization**: Docker (multi-stage build)
- **CI/CD**: GitHub Actions
- **Hosting**: Vercel
- **Data Source**: [worldcup26.ir](https://worldcup26.ir) (no auth required)
## Local Development
```bash
npm install
npm run dev
```
Open [http://localhost:3000](http://localhost:3000) in your browser.
## Docker
Build and run the production container:
```bash
docker build -t worldcup-dashboard .
docker run -p 3000:3000 worldcup-dashboard
```
The multi-stage build produces a standalone image under 200MB.
## Environment Variables
Copy `.env.local.example` to `.env.local` and fill in your values:
| Variable | Required | Description |
|----------|----------|-------------|
| `AWS_ACCESS_KEY_ID` | Secret Mission only | AWS access key for Bedrock |
| `AWS_SECRET_ACCESS_KEY` | Secret Mission only | AWS secret key for Bedrock |
| `AWS_REGION` | Secret Mission only | AWS region (default: us-east-1) |
## Deployment
This project deploys automatically via GitHub Actions on every push to `main`:
1. Installs dependencies and runs ESLint
2. Verifies the Docker build
3. Builds and deploys to Vercel using the prebuilt deployment method
Required GitHub repository secrets:
- `VERCEL_TOKEN`
- `VERCEL_ORG_ID`
- `VERCEL_PROJECT_ID`
## Screenshots
<!-- Add screenshots of your live dashboard here -->
What makes this README portfolio-grade?
- The architecture section shows you understand data flow, not just code.
- The tech stack section communicates your skills at a glance.
- Local development and Docker sections prove the project is reproducible.
- The deployment section documents your CI/CD setup for anyone reviewing your work.
- Save both .env.local.example and README.md.
- Push everything to main to trigger the workflow by running:
git add .
git commit -m "Add CI/CD workflow and README"
git push origin main
- Open your repository on GitHub.
- Click the **Actions** tab at the top of the page.
- You should see a workflow run called **Deploy to Vercel** in progress.
The workflow takes a few minutes to complete all steps. Once you see a green checkmark, your dashboard is live at a public .vercel.app URL.
Workflow failed?
- Click the failed run to see which step errored.
- If the **Lint** step failed, run npm run lint locally to see the issues and fix them before pushing again.
- If the **Pull Vercel Environment Information** step failed, double-check that VERCEL_TOKEN, VERCEL_ORG_ID, and VERCEL_PROJECT_ID are set correctly in your GitHub repository secrets.
Help me debug my failed GitHub Actions workflow.
✔️ Awesome, I've got everything!
Great. Double check you have saved all files.
ⓧ I'd like to double check the full code
Here is the complete deploy.yml workflow file:
name: Deploy to Vercel
env:
VERCEL_ORG_ID: ${{ secrets.VERCEL_ORG_ID }}
VERCEL_PROJECT_ID: ${{ secrets.VERCEL_PROJECT_ID }}
on:
push:
branches:
- main
jobs:
deploy:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: "22"
- name: Install dependencies
run: npm ci
- name: Lint
run: npm run lint
- name: Verify Docker build
run: docker build -t worldcup-dashboard .
- name: Install Vercel CLI
run: npm install --global vercel@latest
- name: Pull Vercel Environment Information
run: vercel pull --yes --environment=production --token=${{ secrets.VERCEL_TOKEN }}
- name: Build Project Artifacts
run: vercel build --prod --token=${{ secrets.VERCEL_TOKEN }}
- name: Deploy to Vercel
run: vercel deploy --prebuilt --prod --token=${{ secrets.VERCEL_TOKEN }}
Here is the .env.local.example file:
# AWS Bedrock (Secret Mission only)
AWS_ACCESS_KEY_ID=your-access-key-here
AWS_SECRET_ACCESS_KEY=your-secret-key-here
AWS_REGION=us-east-1
Secret mission
AI Match Insights with AWS Bedrock
Your dashboard shows scores, but what if it could explain what happened tactically? In this secret mission, you will connect AWS Bedrock to your dashboard so that finished matches display an AI-generated tactical summary powered by Claude Sonnet 5.
Clean Up Your Resources
Clean Up Your Resources
This project uses Vercel's free tier and local Docker images, so there are no ongoing costs for the main project. Decide whether to keep your resources running, pause them to come back later, or delete them entirely.
Cost warning
If you completed the Secret Mission, AWS Bedrock charges per API call. At promotional pricing ($2 per 1M input tokens, $10 per 1M output tokens), each match insight costs well under $0.01. However, leaving the feature deployed means anyone visiting your site could trigger Bedrock calls.
Consider removing the AWS environment variables from Vercel if you want to disable the feature without deleting your project.
Resources you used:
- Vercel project (free tier, hosting your dashboard).
- GitHub repository (free, stores your code and CI/CD workflow).
- Local Docker image (worldcup-dashboard, stored on your machine).
- AWS Bedrock usage (Secret Mission only, pay-per-token).
Keep everything running
No action needed. Choose this if you are still actively building or want the dashboard live through July 19, 2026 (end of the tournament).
- Your Vercel deployment stays live on the free tier with no idle charges.
- Your GitHub repository remains available for future pushes.
- The local Docker image stays on your machine (uses disk space only).
- If you completed the Secret Mission, AWS Bedrock calls will continue to incur per-token costs whenever someone clicks the insight button on your live site.
Pause - I'll come back to this later
Shut down running processes to free up local resources while keeping everything intact for later.
- Stop any running Docker containers by pressing Ctrl+C in the terminal where the container is running.
- Vercel's free tier has no idle charges, so your deployment can stay live at no cost.
- If you completed the Secret Mission and want to avoid Bedrock charges while paused, remove the AWS environment variables from your Vercel project settings.
Delete - I don't want to use this again
Remove all project resources and start fresh. Follow each step below for a complete cleanup.
- Remove the Vercel project by running:
vercel rm worldcup-dashboard
This deletes all deployments, domains, and environment variables associated with the project.
- Remove the local Docker image by running:
docker rmi worldcup-dashboard
- Delete the GitHub repository if you no longer need it. Go to your repository on GitHub, click Settings, scroll to the bottom of the General page, and click Delete this repository.
- Delete your local project folder by running:
cd ~/Desktop
rm -rf worldcup-dashboard
- If you completed the Secret Mission, no additional AWS cleanup is needed beyond removing the Vercel project (which removes the environment variables that allow Bedrock calls).
That's a Wrap!
That's a Wrap!
Nice work! You just shipped a production-ready World Cup 2026 dashboard from scratch, containerized it with Docker, and automated the entire deployment with a CI/CD pipeline.
You've learned how to:
- Build a Next.js dashboard with server components that consumes live data from a free REST API, including TypeScript types, error boundaries, loading skeletons, and auto-refreshing.
- Containerize a full-stack application using a multi-stage Docker build that produces a minimal standalone image ready for any cloud platform.
- Create a GitHub Actions CI/CD pipeline that lints, validates the Docker build, and deploys to Vercel automatically on every push to main.
- (Secret Mission) Integrated AWS Bedrock to generate AI-powered tactical match summaries for completed games using Claude Sonnet 5.
Ready to quiz yourself?