How to Build a Football Stats Dashboard with Next.js & TheStatsAPI
Build a football stats dashboard in Next.js using TheStatsAPI. Step-by-step tutorial: fetch fixtures, display match results, and show player stats. With full code.
Next.js is the natural choice for building a football stats dashboard. Server-side data fetching keeps your API key secure, the App Router makes caching trivial, and you can deploy to Vercel in minutes. In this tutorial, we will build a working football stats dashboard that displays match fixtures, results, and player statistics using TheStatsAPI.
By the end, you will have a Next.js app that fetches real football data server-side, renders match cards with Tailwind CSS, and caches responses intelligently to stay within your API quota. The full project takes about 30 minutes to build.
What You Will Build
- A homepage showing recent and upcoming matches for a selected competition
- Match cards displaying team names, scores, date, and match status
- A player stats page showing top scorers and their season statistics
- Server-side data fetching with built-in caching via Next.js
revalidate
Project Setup
Create a new Next.js app with the App Router and Tailwind CSS:
npx create-next-app@latest football-dashboard --app --tailwind --typescript --eslint --src-dir --import-alias "@/*"
cd football-dashboard
This gives you a Next.js project with the App Router, TypeScript, Tailwind CSS, and ESLint preconfigured. No additional packages are needed - we will use the built-in fetch API for all data fetching.
Environment Setup
Create a .env.local file in the project root to store your API key:
THESTATSAPI_KEY=your_api_key_here
THESTATSAPI_BASE_URL=https://api.thestatsapi.com/api
If you do not have an API key yet, sign up at thestatsapi.com for a 7-day free trial. The trial gives you full access to all endpoints and all 1,196 competitions.
Next, create a utility function for making API calls. Create src/lib/api.ts:
const API_KEY = process.env.THESTATSAPI_KEY!;
const BASE_URL = process.env.THESTATSAPI_BASE_URL!;
export async function fetchAPI<T>(
endpoint: string,
params?: Record<string, string>,
revalidate: number = 3600
): Promise<T> {
const url = new URL(`${BASE_URL}${endpoint}`);
if (params) {
Object.entries(params).forEach(([key, value]) => {
url.searchParams.set(key, value);
});
}
const response = await fetch(url.toString(), {
headers: {
Authorization: `Bearer ${API_KEY}`,
Accept: "application/json",
},
next: { revalidate },
});
if (!response.ok) {
throw new Error(`API error: ${response.status} ${response.statusText}`);
}
return response.json();
}
This utility handles authentication, query parameters, and Next.js caching in one place. The revalidate parameter controls how long cached data is considered fresh - we default to 3,600 seconds (1 hour).
Defining Types
Create src/lib/types.ts for the data structures we will use:
export interface Team {
id: number;
name: string;
logo?: string;
}
export interface Match {
id: number;
home_team: Team;
away_team: Team;
home_score: number | null;
away_score: number | null;
status: string;
date: string;
competition: {
id: number;
name: string;
};
}
export interface PlayerSeason {
season: string;
competition: { name: string };
appearances: number;
goals: number;
assists: number;
minutes_played: number;
yellow_cards: number;
red_cards: number;
}
export interface Player {
id: number;
name: string;
position: string;
nationality: string;
team: Team;
seasons?: PlayerSeason[];
}
export interface APIResponse<T> {
data: T;
meta: {
current_page: number;
last_page: number;
total: number;
};
}
Fetching Matches Server-Side
Replace the contents of src/app/page.tsx with a server component that fetches and displays matches:
import { fetchAPI } from "@/lib/api";
import { Match, APIResponse } from "@/lib/types";
function formatDate(dateString: string) {
return new Date(dateString).toLocaleDateString("en-GB", {
weekday: "short",
day: "numeric",
month: "short",
year: "numeric",
});
}
function StatusBadge({ status }: { status: string }) {
const colors: Record<string, string> = {
finished: "bg-green-100 text-green-800",
scheduled: "bg-blue-100 text-blue-800",
postponed: "bg-yellow-100 text-yellow-800",
};
return (
<span
className={`px-2 py-1 rounded-full text-xs font-medium ${
colors[status] || "bg-gray-100 text-gray-800"
}`}
>
{status}
</span>
);
}
function MatchCard({ match }: { match: Match }) {
return (
<div className="bg-white rounded-lg shadow-sm border border-gray-200 p-4 hover:shadow-md transition-shadow">
<div className="flex justify-between items-center mb-3">
<span className="text-xs text-gray-500">
{formatDate(match.date)}
</span>
<StatusBadge status={match.status} />
</div>
<div className="flex items-center justify-between">
<div className="flex-1 text-right pr-4">
<p className="font-semibold text-gray-900">
{match.home_team.name}
</p>
</div>
<div className="flex items-center gap-2 px-3 py-1 bg-gray-50 rounded">
<span className="text-xl font-bold text-gray-900">
{match.home_score ?? "-"}
</span>
<span className="text-gray-400">:</span>
<span className="text-xl font-bold text-gray-900">
{match.away_score ?? "-"}
</span>
</div>
<div className="flex-1 pl-4">
<p className="font-semibold text-gray-900">
{match.away_team.name}
</p>
</div>
</div>
<p className="text-xs text-gray-400 mt-3 text-center">
{match.competition.name}
</p>
</div>
);
}
export default async function HomePage() {
const data = await fetchAPI<APIResponse<Match[]>>(
"/football/matches",
{ competition_id: "1" },
3600 // Revalidate every hour
);
const matches = data.data;
return (
<main className="max-w-3xl mx-auto px-4 py-8">
<h1 className="text-3xl font-bold text-gray-900 mb-2">
Football Dashboard
</h1>
<p className="text-gray-600 mb-8">
Latest match results and upcoming fixtures
</p>
<div className="grid gap-4">
{matches.map((match) => (
<MatchCard key={match.id} match={match} />
))}
</div>
{matches.length === 0 && (
<p className="text-center text-gray-500 py-12">
No matches found for this competition.
</p>
)}
</main>
);
}
This is a React Server Component - it runs entirely on the server. Your API key never reaches the browser. The fetchAPI call with revalidate: 3600 means Next.js will cache this data for one hour before fetching fresh results.
Adding Player Stats
Create a player stats page at src/app/players/page.tsx:
import { fetchAPI } from "@/lib/api";
import { Player, APIResponse } from "@/lib/types";
function PlayerCard({ player }: { player: Player }) {
return (
<div className="bg-white rounded-lg shadow-sm border border-gray-200 p-4">
<div className="flex justify-between items-start mb-2">
<div>
<h3 className="font-semibold text-gray-900">{player.name}</h3>
<p className="text-sm text-gray-500">
{player.team?.name} · {player.position}
</p>
</div>
<span className="text-xs text-gray-400">{player.nationality}</span>
</div>
{player.seasons && player.seasons.length > 0 && (
<div className="mt-3 pt-3 border-t border-gray-100">
<div className="grid grid-cols-4 gap-2 text-center">
<div>
<p className="text-lg font-bold text-gray-900">
{player.seasons[0].goals}
</p>
<p className="text-xs text-gray-500">Goals</p>
</div>
<div>
<p className="text-lg font-bold text-gray-900">
{player.seasons[0].assists}
</p>
<p className="text-xs text-gray-500">Assists</p>
</div>
<div>
<p className="text-lg font-bold text-gray-900">
{player.seasons[0].appearances}
</p>
<p className="text-xs text-gray-500">Apps</p>
</div>
<div>
<p className="text-lg font-bold text-gray-900">
{player.seasons[0].minutes_played}
</p>
<p className="text-xs text-gray-500">Mins</p>
</div>
</div>
</div>
)}
</div>
);
}
export default async function PlayersPage() {
const data = await fetchAPI<APIResponse<Player[]>>(
"/football/players",
{ competition_id: "1" },
3600
);
const players = data.data;
return (
<main className="max-w-3xl mx-auto px-4 py-8">
<h1 className="text-3xl font-bold text-gray-900 mb-2">
Player Statistics
</h1>
<p className="text-gray-600 mb-8">
Season stats for all players in the competition
</p>
<div className="grid gap-4 sm:grid-cols-2">
{players.map((player) => (
<PlayerCard key={player.id} player={player} />
))}
</div>
</main>
);
}
Adding Navigation
Create a simple navigation component at src/components/nav.tsx:
import Link from "next/link";
export function Nav() {
return (
<nav className="bg-white border-b border-gray-200 px-4 py-3">
<div className="max-w-3xl mx-auto flex gap-6">
<Link
href="/"
className="text-sm font-medium text-gray-700 hover:text-gray-900"
>
Matches
</Link>
<Link
href="/players"
className="text-sm font-medium text-gray-700 hover:text-gray-900"
>
Players
</Link>
</div>
</nav>
);
}
Then add it to your src/app/layout.tsx:
import { Nav } from "@/components/nav";
// ... existing layout code
export default function RootLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<html lang="en">
<body>
<Nav />
{children}
</body>
</html>
);
}
Caching with Next.js
Next.js App Router has built-in caching that works perfectly for sports data. Here is how to think about cache durations for football data:
Cache strategy by data type
| Data type | Revalidate interval | Rationale |
|---|---|---|
| Match results | 3,600s (1 hour) | Post-match data updates within 1-2 hours of full time |
| Upcoming fixtures | 21,600s (6 hours) | Fixture schedules rarely change |
| Player season stats | 21,600s (6 hours) | Stats only update after matches |
| Competition list | 86,400s (24 hours) | Competitions almost never change mid-season |
| Player profiles | 86,400s (24 hours) | Names, nationalities, and positions are stable |
You can set different revalidation intervals for different data by passing the revalidate parameter to our fetchAPI utility:
// Match results - refresh every hour
const matches = await fetchAPI<APIResponse<Match[]>>(
"/football/matches",
{ competition_id: "1" },
3600
);
// Competition list - refresh once a day
const competitions = await fetchAPI<APIResponse<Competition[]>>(
"/football/competitions",
undefined,
86400
);
Why this matters for your API quota
On TheStatsAPI's Starter plan, you get 100,000 requests per month. Without caching, a dashboard that fetches matches and player stats on every page view would burn through that quota fast. With 1-hour caching, a page visited 1,000 times per hour still only makes one API call per hour. Over a month, that is roughly 720 calls for one endpoint instead of 720,000.
Deployment
Deploying to Vercel takes two steps:
- Push your code to a GitHub repository.
- Go to vercel.com, import the repository, and add your environment variables (
THESTATSAPI_KEYandTHESTATSAPI_BASE_URL) in the project settings.
Vercel automatically detects Next.js, runs the build, and deploys. Your server components run on Vercel's edge network, and the revalidate caching works out of the box with Vercel's data cache.
Environment variables checklist
Make sure these are set in your Vercel project settings:
THESTATSAPI_KEY=your_api_key_here
THESTATSAPI_BASE_URL=https://api.thestatsapi.com/api
Do not commit .env.local to your repository. Add it to .gitignore (Next.js does this by default).
Where to Go from Here
This tutorial gives you a working foundation. Here are some ways to extend it:
- Add competition switching. Fetch the list of competitions from
/football/competitionsand let users select which league to view. TheStatsAPI covers 1,196 competitions, so there is no shortage of data. - Add match detail pages. Create dynamic routes at
src/app/matches/[id]/page.tsxto show full match details - lineups, events, and statistics. - Add search. Use the player search endpoint (
/football/players?search=) to let users find any player from the 84,000+ in the database. - Add historical views. Let users browse past seasons using the
seasonquery parameter. With 20+ years of data available, you can build rich historical comparisons. - Style it properly. The Tailwind CSS in this tutorial is intentionally minimal. Replace it with your own design system or add a component library like shadcn/ui.
TheStatsAPI offers a 7-day free trial on all plans, giving full access to all 1,196 competitions and 84,000+ players - making it the best way to evaluate a premium football API before committing. Sign up at thestatsapi.com and start building your dashboard today.
Ready to Power Your Sports App?
Start your 7-day free trial. All endpoints included on every plan.