DEV Community

Miheve
Miheve

Posted on

I Built a Football Livescore App That Still Works Without an API Key

screenshot

A football livescore app looks simple until you try to build one properly.

At first, the idea was straightforward:

  • show live matches
  • let users browse fixtures by date
  • organize games by league
  • add a match details page
  • display team and competition logos

But once I started building it, the real challenge was not rendering a score.

The harder part was deciding how the data layer should behave when:

  • the API key is missing
  • the external API is unavailable
  • the response shape changes
  • a team logo does not exist
  • the UI needs to work before the live integration is ready

That led me to build PitchPulse Live, a production-style football livescore application using Next.js, TypeScript, Tailwind CSS, and SportMicro football data.

The part I like most is that the application does not become unusable when no API key is configured.

It automatically switches to mock data.

This means someone can clone the repository, run the app, explore the interface, and continue development without first setting up access to an external sports API.

Repository:

github.com/mihailove123/pitchpulse-livescore-ap


What I Wanted to Build

I did not want the project to be only a page with several hardcoded match cards.

The goal was to create a small but realistic football product with a structure that could later support more advanced features.

The first version includes:

  • a live match scoreboard
  • daily fixture browsing
  • league filtering
  • a league directory
  • individual match pages
  • team and league logos
  • server-rendered data fetching
  • automatic mock fallback

The main routes are:

/               Live matches and daily fixtures
/leagues        Football league directory
/matches/[id]   Match details
Enter fullscreen mode Exit fullscreen mode

The app is built with the Next.js App Router.

That allowed me to keep the initial data fetching on the server while still using interactive client components for date selection and league filtering.


The Stack

The project uses:

Next.js 16
React 19
TypeScript
Tailwind CSS 4
SportMicro Football API
@sportmicro/endpoint
Enter fullscreen mode Exit fullscreen mode

The project structure looks like this:

src/
  app/
    page.tsx
    leagues/
      page.tsx
    matches/
      [id]/
        page.tsx

  components/
    EntityLogo.tsx
    MatchCard.tsx
    MatchList.tsx
    LeagueFilter.tsx
    DateSelector.tsx

  lib/
    sportmicro.ts
    mock-data.ts
    utils.ts

  types/
    sportmicro.ts

public/
  screenshots/
    sc.png
Enter fullscreen mode Exit fullscreen mode

The important design decision was to keep the football data logic separate from the UI.

The pages should not need to know how authentication headers are created, how query parameters are built, or how raw API responses are normalized.

They should only receive app-friendly objects and render them.


The First Problem: External Data Should Not Control the Entire App

When building an API-powered interface, it is tempting to connect the UI directly to the raw response.

For example:

<div>
  <span>{match.home_team?.name}</span>
  <span>{match.score?.home}</span>
  <span>{match.score?.away}</span>
  <span>{match.away_team?.name}</span>
</div>
Enter fullscreen mode Exit fullscreen mode

This works initially, but it creates a tight dependency between the interface and the external API.

If the API changes a field name, every component using that field may need to change.

Instead, PitchPulse normalizes the responses inside the data layer.

The UI consumes internal types such as:

export type FootballMatch = {
  id: string
  homeTeam: {
    id: string
    name: string
    logo?: string
  }
  awayTeam: {
    id: string
    name: string
    logo?: string
  }
  homeScore: number | null
  awayScore: number | null
  status: string
  startTime: string
  league: {
    id: string
    name: string
    logo?: string
  }
}
Enter fullscreen mode Exit fullscreen mode

This creates a useful boundary:

External API response
        ↓
Normalization layer
        ↓
Application types
        ↓
React components
Enter fullscreen mode Exit fullscreen mode

The components no longer care where the data came from.

That becomes especially useful because the same components can render both live API data and local mock data.


Using @sportmicro/endpoint as a Query Builder

The project uses the @sportmicro/endpoint package, but not as a full API client.

I use it to construct PostgREST-style endpoint paths.

For example:

const path = endpoint("matches")
  .property("status_type")
  .equals("live")
  .select(
    "id",
    "start_time",
    "status_type",
    "home_team",
    "away_team",
    "home_score",
    "away_score"
  )
  .limit("12")
  .order((order) =>
    order.property("start_time").ascending
  )
  .toString()
Enter fullscreen mode Exit fullscreen mode

The package handles the query structure.

The application still sends the actual HTTP request with fetch.

const response = await fetch(
  `${FOOTBALL_API_URL}/${path}?lang=en`,
  {
    headers: {
      Authorization: `Bearer ${SPORTMICRO_API_KEY}`,
      Accept: "application/json",
    },
    next: {
      revalidate: 30,
    },
  }
)
Enter fullscreen mode Exit fullscreen mode

I like this approach because it keeps endpoint construction readable.

Instead of manually building a long query string:

const url =
  "/matches?status_type=eq.live&select=id,start_time,status_type..."
Enter fullscreen mode Exit fullscreen mode

the code describes the query step by step.


One Wrapper for All Football Requests

The SportMicro integration lives in:

src/lib/sportmicro.ts
Enter fullscreen mode Exit fullscreen mode

That file is responsible for:

  • building endpoint paths
  • attaching the API key
  • sending requests
  • handling errors
  • switching to mock data
  • normalizing responses

The rest of the application calls functions such as:

getLiveFootballMatches()
getFixturesByDate(date)
getMatchDetails(matchId)
getLeagues()
Enter fullscreen mode Exit fullscreen mode

A page does not need to know which endpoint is being used.

export default async function HomePage() {
  const liveMatches =
    await getLiveFootballMatches()

  const fixtures =
    await getFixturesByDate(today)

  return (
    <main>
      <MatchList matches={liveMatches} />
      <MatchList matches={fixtures} />
    </main>
  )
}
Enter fullscreen mode Exit fullscreen mode

This keeps the page focused on presentation.

The transport logic remains in one place.


Why I Added Mock Mode

The mock fallback became one of the most useful parts of the project.

Normally, an API-powered repository creates friction for anyone trying to run it.

They clone the project, install the dependencies, and immediately see an error:

Missing API key
Enter fullscreen mode Exit fullscreen mode

That is not a great first experience.

In PitchPulse Live, the application checks whether the environment variable exists.

const apiKey = process.env.SPORTMICRO_API_KEY

const isMockMode = !apiKey
Enter fullscreen mode Exit fullscreen mode

When the key is missing, the data functions return local sample data.

export async function getLiveFootballMatches() {
  if (isMockMode) {
    return mockLiveMatches
  }

  return fetchLiveMatchesFromApi()
}
Enter fullscreen mode Exit fullscreen mode

That means the setup can be as simple as:

git clone https://github.com/mihailove123/pitchpulse-livescore-ap.git

cd pitchpulse-livescore-ap

npm install

npm run dev
Enter fullscreen mode Exit fullscreen mode

No external account is required to inspect the layout.

No secret needs to be configured to work on the components.

The live integration can be enabled later by creating .env.local:

SPORTMICRO_API_KEY=your_sportmicro_api_key_here
Enter fullscreen mode Exit fullscreen mode

This creates two useful development modes.

Mock mode
  UI development
  component testing
  layout work
  demos
  local onboarding

Live mode
  real scores
  real fixtures
  real leagues
  real match details
Enter fullscreen mode Exit fullscreen mode

The same UI works in both modes because both data sources are normalized into the same types.


Making Missing Logos Look Intentional

Football data is rarely perfectly complete.

Some teams have logos.

Some do not.

Some image URLs fail.

Some lower-level leagues may have incomplete branding.

Rendering a broken image icon makes the whole application look unfinished.

That is why I added a reusable EntityLogo component.

type EntityLogoProps = {
  name: string
  imageUrl?: string | null
}

export function EntityLogo({
  name,
  imageUrl,
}: EntityLogoProps) {
  if (imageUrl) {
    return (
      <img
        src={imageUrl}
        alt={`${name} logo`}
        className="h-10 w-10 object-contain"
      />
    )
  }

  const initials = name
    .split(" ")
    .map((word) => word[0])
    .join("")
    .slice(0, 2)
    .toUpperCase()

  return (
    <div className="flex h-10 w-10 items-center justify-center rounded-full bg-neutral-800 text-sm font-semibold">
      {initials}
    </div>
  )
}
Enter fullscreen mode Exit fullscreen mode

Remote images are loaded from:

https://images.sportmicro.com/{hash}.png
Enter fullscreen mode Exit fullscreen mode

If no valid image is available, the component displays initials instead.

This is a small feature, but it prevents incomplete data from damaging the interface.

The fallback looks intentional rather than accidental.


Building Reusable Match Cards

The main reusable UI unit is MatchCard.tsx.

A match card needs to support several states:

Scheduled
Live
Finished
Postponed
Cancelled
Enter fullscreen mode Exit fullscreen mode

A scheduled match should emphasize kickoff time.

A live match should emphasize the score and current status.

A completed match should make it clear that the score is final.

A simplified version looks like this:

export function MatchCard({
  match,
}: {
  match: FootballMatch
}) {
  const isScheduled =
    match.status === "scheduled"

  const isLive =
    match.status === "live"

  return (
    <article className="rounded-xl border p-4">
      <div className="flex items-center justify-between">
        <Team
          name={match.homeTeam.name}
          logo={match.homeTeam.logo}
        />

        <div className="text-center">
          {isScheduled ? (
            <time dateTime={match.startTime}>
              {formatMatchTime(match.startTime)}
            </time>
          ) : (
            <strong className="text-xl">
              {match.homeScore ?? 0}
              {" - "}
              {match.awayScore ?? 0}
            </strong>
          )}

          {isLive && (
            <span className="block text-xs">
              LIVE
            </span>
          )}
        </div>

        <Team
          name={match.awayTeam.name}
          logo={match.awayTeam.logo}
        />
      </div>
    </article>
  )
}
Enter fullscreen mode Exit fullscreen mode

Because the card consumes normalized match data, it can be reused on:

  • the homepage
  • fixture results
  • league pages
  • related-match sections
  • search results

Date and League Filtering

The homepage includes controls for browsing fixtures.

The user can select a date and narrow the list by league.

The date selector updates the requested day:

<DateSelector selectedDate={date} />
Enter fullscreen mode Exit fullscreen mode

The league filter receives the competitions found in the returned fixtures:

<LeagueFilter
  leagues={availableLeagues}
  selectedLeague={leagueId}
/>
Enter fullscreen mode Exit fullscreen mode

The filtering logic stays separate from the match card.

That separation matters because the match component should not decide which matches are visible.

Its job is only to render one match correctly.


Match Details as a Dynamic Route

Each match links to:

/matches/[id]
Enter fullscreen mode Exit fullscreen mode

The page reads the dynamic route parameter and loads the details.

type MatchPageProps = {
  params: Promise<{
    id: string
  }>
}

export default async function MatchPage({
  params,
}: MatchPageProps) {
  const { id } = await params

  const match =
    await getMatchDetails(id)

  return (
    <main>
      <MatchHeader match={match} />
      <MatchSummary match={match} />
    </main>
  )
}
Enter fullscreen mode Exit fullscreen mode

The current page displays a score snapshot and match summary.

The route is intentionally designed so it can later support:

  • incidents
  • goals
  • cards
  • substitutions
  • lineups
  • statistics
  • head-to-head results

That is one of the advantages of starting with a clear route and data layer rather than placing everything on one page.


Server Rendering Keeps the Pages Simple

The application uses server-rendered data fetching.

This means pages can request their data before rendering.

export default async function LeaguesPage() {
  const leagues = await getLeagues()

  return (
    <main>
      <h1>Football Leagues</h1>
      <LeagueGrid leagues={leagues} />
    </main>
  )
}
Enter fullscreen mode Exit fullscreen mode

There is no initial client-side loading effect.

There is no empty page waiting for JavaScript to fetch the first result.

Interactive pieces such as the date selector can still be client components, but the initial football data is rendered on the server.

This gives the project a clean split:

Server Components
  data fetching
  page composition
  initial rendering

Client Components
  filters
  date controls
  interactive state
Enter fullscreen mode Exit fullscreen mode

The Complete Data Flow

A typical request moves through the project like this:

User opens the homepage
        ↓
Next.js page calls getLiveFootballMatches()
        ↓
The helper checks for SPORTMICRO_API_KEY
        ↓
No key?
  Return normalized mock data
        ↓
Key exists?
  Build the API path with @sportmicro/endpoint
        ↓
Send the authenticated request with fetch
        ↓
Normalize the response
        ↓
Render MatchList
        ↓
MatchList renders MatchCard components
Enter fullscreen mode Exit fullscreen mode

The UI never needs separate code for live and mock mode.

That is the main benefit of normalizing the data before it reaches the components.


Running the Project

Clone the repository:

git clone https://github.com/mihailove123/pitchpulse-livescore-ap.git
Enter fullscreen mode Exit fullscreen mode

Enter the project:

cd pitchpulse-livescore-ap
Enter fullscreen mode Exit fullscreen mode

Install dependencies:

npm install
Enter fullscreen mode Exit fullscreen mode

Run the development server:

npm run dev
Enter fullscreen mode Exit fullscreen mode

Open:

http://localhost:3000
Enter fullscreen mode Exit fullscreen mode

The application will automatically run with sample data.

To enable live football data, create:

.env.local
Enter fullscreen mode Exit fullscreen mode

Add:

SPORTMICRO_API_KEY=your_sportmicro_api_key_here
Enter fullscreen mode Exit fullscreen mode

Then restart the development server.


Available Commands

npm run dev
npm run build
npm run start
npm run lint
npm run typecheck
Enter fullscreen mode Exit fullscreen mode

Before deploying, I recommend running:

npm run lint
npm run typecheck
npm run build
Enter fullscreen mode Exit fullscreen mode

This catches type errors and production build issues before they reach the hosting platform.


Deployment

The repository can be deployed to Vercel or another Next.js-compatible platform.

A basic deployment flow is:

Push the repository to GitHub
        ↓
Import the repository into Vercel
        ↓
Add SPORTMICRO_API_KEY
        ↓
Deploy
Enter fullscreen mode Exit fullscreen mode

Mock mode can also be useful for preview deployments where live credentials should not be available.


What I Would Build Next

The current repository provides a foundation rather than every possible football feature.

The next additions could include:

  • league standings
  • match incidents
  • team lineups
  • player statistics
  • team pages
  • league-specific fixture pages
  • favorite teams
  • goal notifications
  • live polling
  • match search
  • user time zones

The existing structure makes these additions easier because the transport and UI layers are already separated.

New API methods can be added to:

src/lib/sportmicro.ts
Enter fullscreen mode Exit fullscreen mode

New normalized types can be added to:

src/types/sportmicro.ts
Enter fullscreen mode Exit fullscreen mode

The components can continue receiving predictable application objects.


What I Learned

The most important lesson from this project was not about football data.

It was about fallback design.

An API integration should not make the entire application impossible to run without the API.

By keeping mock and live data behind the same functions, the project becomes:

  • easier to clone
  • easier to test
  • easier to demonstrate
  • easier to contribute to
  • easier to develop offline

The second lesson was to normalize external data early.

Raw API responses are useful at the network boundary.

They should not become the language spoken by the entire application.

The final architecture is simple:

Query builder
      ↓
Authenticated request
      ↓
Normalized application data
      ↓
Reusable football components
Enter fullscreen mode Exit fullscreen mode

That structure is what turns a football API demo into an application that can continue growing.

The source code is available here:

PitchPulse Live on GitHub

I would be interested to hear how other developers handle mock mode in API-powered projects. Do you keep static fixtures, use a local mock server, or make the real integration optional?

Top comments (0)