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
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
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
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>
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
}
}
This creates a useful boundary:
External API response
↓
Normalization layer
↓
Application types
↓
React components
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()
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,
},
}
)
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..."
the code describes the query step by step.
One Wrapper for All Football Requests
The SportMicro integration lives in:
src/lib/sportmicro.ts
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()
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>
)
}
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
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
When the key is missing, the data functions return local sample data.
export async function getLiveFootballMatches() {
if (isMockMode) {
return mockLiveMatches
}
return fetchLiveMatchesFromApi()
}
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
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
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
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>
)
}
Remote images are loaded from:
https://images.sportmicro.com/{hash}.png
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
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>
)
}
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} />
The league filter receives the competitions found in the returned fixtures:
<LeagueFilter
leagues={availableLeagues}
selectedLeague={leagueId}
/>
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]
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>
)
}
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>
)
}
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
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
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 the project:
cd pitchpulse-livescore-ap
Install dependencies:
npm install
Run the development server:
npm run dev
Open:
http://localhost:3000
The application will automatically run with sample data.
To enable live football data, create:
.env.local
Add:
SPORTMICRO_API_KEY=your_sportmicro_api_key_here
Then restart the development server.
Available Commands
npm run dev
npm run build
npm run start
npm run lint
npm run typecheck
Before deploying, I recommend running:
npm run lint
npm run typecheck
npm run build
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
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
New normalized types can be added to:
src/types/sportmicro.ts
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
That structure is what turns a football API demo into an application that can continue growing.
The source code is available here:
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)