TUTORIAL

    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.

    Last updated: April 1, 20269 min read

    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} &middot; {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 typeRevalidate intervalRationale
    Match results3,600s (1 hour)Post-match data updates within 1-2 hours of full time
    Upcoming fixtures21,600s (6 hours)Fixture schedules rarely change
    Player season stats21,600s (6 hours)Stats only update after matches
    Competition list86,400s (24 hours)Competitions almost never change mid-season
    Player profiles86,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:

    1. Push your code to a GitHub repository.
    2. Go to vercel.com, import the repository, and add your environment variables (THESTATSAPI_KEY and THESTATSAPI_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/competitions and 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.tsx to 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 season query 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.

    Start building today

    Ready to Power Your Sports App?

    Start your 7-day free trial. All endpoints included on every plan.

    Cancel anytime
    7-day free trial
    Setup in 5 minutes