Building a football scores app looked easy when I first planned it.
Fetch today's matches.
Display the teams.
Show the score.
Refresh the page every few seconds.
That was the entire architecture.
During development, it worked perfectly. I tested it with a few sample matches, opened the page in two browser tabs, and watched the scores update.
Then the first busy matchday arrived.
Several games started at the same time. Hundreds of users opened the app. Requests began overlapping. Some scores appeared to move backward. Finished matches continued refreshing, and the same football data was fetched separately for every visitor.
The API was working correctly.
The frontend was rendering what it received.
The problem was everything between them.
That day taught me that a football livescore app is not simply a website connected to an API.
It is a live data synchronization system.
The First Version
My first implementation was a client-side component with a polling interval.
"use client"
import { useEffect, useState } from "react"
type Match = {
id: string
homeTeam: string
awayTeam: string
homeScore: number
awayScore: number
}
export default function LiveMatches() {
const [matches, setMatches] = useState<Match[]>([])
useEffect(() => {
async function fetchMatches() {
const response = await fetch("/api/matches/live")
const data = await response.json()
setMatches(data.matches)
}
fetchMatches()
const intervalId = window.setInterval(
fetchMatches,
5000
)
return () => {
window.clearInterval(intervalId)
}
}, [])
return (
<div>
{matches.map((match) => (
<article key={match.id}>
<span>{match.homeTeam}</span>
<strong>
{match.homeScore} - {match.awayScore}
</strong>
<span>{match.awayTeam}</span>
</article>
))}
</div>
)
}
It looked reasonable.
Every five seconds, the app requested the newest scores and replaced the current state.
The problem became obvious when the number of users increased.
1 user = 12 requests per minute
100 users = 1,200 requests per minute
1,000 users = 12,000 requests per minute
Most of those requests were asking for exactly the same data.
Every browser was independently fetching the same matches.
The app had no shared cache, no request coordination, and no understanding of whether the matches could still change.
A Live Score Does Not Need to Be Fetched Separately for Every User
When two users open the same match page, they usually need the same score.
The score is not personalized.
That means the server should be able to reuse a recently fetched result instead of requesting the football provider again for every visitor.
A basic server-side request function might look like this:
// lib/football-api.ts
import "server-only"
const baseUrl = process.env.FOOTBALL_API_URL
const apiKey = process.env.FOOTBALL_API_KEY
if (!baseUrl) {
throw new Error("FOOTBALL_API_URL is missing")
}
if (!apiKey) {
throw new Error("FOOTBALL_API_KEY is missing")
}
export async function footballRequest<T>(
endpoint: string,
revalidate = 15
): Promise<T> {
const response = await fetch(
`${baseUrl}${endpoint}`,
{
headers: {
Accept: "application/json",
Authorization: `Bearer ${apiKey}`,
},
next: {
revalidate,
},
}
)
if (!response.ok) {
throw new Error(
`Football API request failed with ${response.status}`
)
}
return response.json() as Promise<T>
}
The important part is not the exact URL or authentication format.
The important part is that the external API request happens on the server.
The browser communicates with your application.
Your application communicates with the football data provider.
This gives you control over:
- API credentials
- Caching
- Error handling
- Rate limiting
- Response formatting
- Logging
Never Expose the Football API Key in the Browser
This is unsafe:
const response = await fetch(
`https://example.com/live?key=${process.env.NEXT_PUBLIC_FOOTBALL_API_KEY}`
)
Variables prefixed with NEXT_PUBLIC_ can be included in client-side JavaScript.
Anyone using the application may be able to inspect the request and copy the key.
Instead, keep the key in a private environment variable:
FOOTBALL_API_URL=https://api.example.com
FOOTBALL_API_KEY=your_private_key
Then import the API client only from server-side files.
import "server-only"
A football app can generate a large number of requests during popular matches. An exposed key can quickly become expensive.
Do Not Use the Raw API Response Everywhere
My next mistake was passing the provider response directly into React components.
The component expected fields such as:
<p>{event.home_team.name}</p>
<p>{event.current_score.home}</p>
<p>{event.match_status}</p>
That created a strong dependency between the UI and one specific response format.
When one endpoint returned homeTeam instead of home_team, I had to add special cases inside the component.
A better approach is to define an internal match type.
// types/match.ts
export type MatchStatus =
| "scheduled"
| "live"
| "halftime"
| "finished"
| "postponed"
| "cancelled"
export type Team = {
id: string
name: string
logoUrl: string | null
}
export type FootballMatch = {
id: string
competition: {
id: string
name: string
country: string | null
}
homeTeam: Team
awayTeam: Team
score: {
home: number | null
away: number | null
}
status: MatchStatus
minute: number | null
startsAt: string
}
Then map the provider response into the application's format.
// lib/map-match.ts
import type {
FootballMatch,
MatchStatus,
} from "@/types/match"
type ApiMatch = {
id: string | number
status?: string
minute?: number | null
start_time?: string
league?: {
id?: string | number
name?: string
country?: string
}
home?: {
id?: string | number
name?: string
logo?: string
}
away?: {
id?: string | number
name?: string
logo?: string
}
scores?: {
home?: number | null
away?: number | null
}
}
export function mapMatch(
match: ApiMatch
): FootballMatch {
return {
id: String(match.id),
competition: {
id: String(match.league?.id ?? "unknown"),
name: match.league?.name ?? "Unknown competition",
country: match.league?.country ?? null,
},
homeTeam: {
id: String(match.home?.id ?? "unknown-home"),
name: match.home?.name ?? "Home team",
logoUrl: match.home?.logo ?? null,
},
awayTeam: {
id: String(match.away?.id ?? "unknown-away"),
name: match.away?.name ?? "Away team",
logoUrl: match.away?.logo ?? null,
},
score: {
home: match.scores?.home ?? null,
away: match.scores?.away ?? null,
},
status: normalizeStatus(match.status),
minute: match.minute ?? null,
startsAt:
match.start_time ?? new Date().toISOString(),
}
}
function normalizeStatus(
status?: string
): MatchStatus {
switch (status?.toLowerCase()) {
case "live":
case "first_half":
case "second_half":
case "in_progress":
return "live"
case "half_time":
case "halftime":
return "halftime"
case "full_time":
case "finished":
case "ended":
return "finished"
case "postponed":
return "postponed"
case "cancelled":
case "canceled":
return "cancelled"
default:
return "scheduled"
}
}
Now the components depend on a stable application model.
If the external API changes, only the mapping layer needs to change.
Render the First Match List on the Server
My first app displayed an empty loading screen until the browser completed its first request.
That was unnecessary.
With Next.js Server Components, the initial matches can be loaded before the page reaches the browser.
// app/page.tsx
import { MatchCenter } from "@/components/match-center"
import { getMatchesByDate } from "@/lib/get-matches"
export default async function HomePage() {
const date = new Date()
.toISOString()
.slice(0, 10)
const matches = await getMatchesByDate(date)
return (
<main>
<header>
<p>Football scores</p>
<h1>Today's Matches</h1>
</header>
<MatchCenter
date={date}
initialMatches={matches}
/>
</main>
)
}
The server-side data function can apply a short cache duration.
// lib/get-matches.ts
import "server-only"
import { footballRequest } from "@/lib/football-api"
import { mapMatch } from "@/lib/map-match"
import type { FootballMatch } from "@/types/match"
type MatchesResponse = {
matches: unknown[]
}
export async function getMatchesByDate(
date: string
): Promise<FootballMatch[]> {
const response =
await footballRequest<MatchesResponse>(
`/matches?date=${encodeURIComponent(date)}`,
30
)
return response.matches.map((match) =>
mapMatch(match as never)
)
}
The visitor sees content immediately.
The client-side code only needs to keep that content updated.
Use Your Own Route Handler for Updates
The browser should not need to know which provider powers the football data.
Create an internal endpoint.
// app/api/matches/route.ts
import { NextRequest, NextResponse } from "next/server"
import { getMatchesByDate } from "@/lib/get-matches"
export async function GET(
request: NextRequest
) {
const date =
request.nextUrl.searchParams.get("date")
if (!date || !isValidDate(date)) {
return NextResponse.json(
{
error:
"A valid date in YYYY-MM-DD format is required",
},
{
status: 400,
}
)
}
try {
const matches = await getMatchesByDate(date)
return NextResponse.json(
{
matches,
updatedAt: new Date().toISOString(),
},
{
headers: {
"Cache-Control":
"public, s-maxage=10, stale-while-revalidate=20",
},
}
)
} catch (error) {
console.error(
"Failed to load football matches",
error
)
return NextResponse.json(
{
error:
"Football data is temporarily unavailable",
},
{
status: 503,
}
)
}
}
function isValidDate(value: string): boolean {
return /^\d{4}-\d{2}-\d{2}$/.test(value)
}
This endpoint becomes the boundary between the browser and the football provider.
Later, you can add:
- Rate limiting
- Authentication
- Analytics
- Provider fallback
- Region-specific caching
- Subscription checks
- Request tracing
Stop Polling When Nothing Is Live
This was the easiest improvement and one of the most valuable.
My original app refreshed the match list every five seconds, even when every match was finished.
A finished score is unlikely to change.
A match scheduled for tomorrow does not need to be requested every few seconds.
The polling logic should depend on the match state.
// components/match-center.tsx
"use client"
import {
useCallback,
useEffect,
useRef,
useState,
} from "react"
import type { FootballMatch } from "@/types/match"
import { MatchList } from "@/components/match-list"
type MatchCenterProps = {
date: string
initialMatches: FootballMatch[]
}
export function MatchCenter({
date,
initialMatches,
}: MatchCenterProps) {
const [matches, setMatches] =
useState(initialMatches)
const [lastUpdated, setLastUpdated] =
useState<string | null>(null)
const [error, setError] =
useState<string | null>(null)
const currentRequest =
useRef<AbortController | null>(null)
const hasLiveMatches = matches.some(
(match) =>
match.status === "live" ||
match.status === "halftime"
)
const refreshMatches = useCallback(
async () => {
currentRequest.current?.abort()
const controller = new AbortController()
currentRequest.current = controller
try {
const response = await fetch(
`/api/matches?date=${encodeURIComponent(date)}`,
{
cache: "no-store",
signal: controller.signal,
}
)
if (!response.ok) {
throw new Error(
`Request failed with ${response.status}`
)
}
const data = await response.json()
setMatches(data.matches)
setLastUpdated(data.updatedAt)
setError(null)
} catch (error) {
if (
error instanceof DOMException &&
error.name === "AbortError"
) {
return
}
console.error(error)
setError(
"Live updates are temporarily delayed."
)
}
},
[date]
)
useEffect(() => {
if (!hasLiveMatches) {
return
}
const intervalId = window.setInterval(
refreshMatches,
15_000
)
return () => {
window.clearInterval(intervalId)
currentRequest.current?.abort()
}
}, [hasLiveMatches, refreshMatches])
return (
<section>
{lastUpdated && (
<small>
Updated{" "}
{new Date(
lastUpdated
).toLocaleTimeString()}
</small>
)}
{error && (
<p role="status">{error}</p>
)}
<MatchList matches={matches} />
</section>
)
}
The important check is:
if (!hasLiveMatches) {
return
}
When no match is live, the refresh interval is not created.
Old Requests Can Overwrite New Scores
Polling introduces a race condition that is easy to miss.
Imagine these two requests:
Request A starts at 14:30:00
Request B starts at 14:30:15
Request B returns first with a score of 2-1
Request A returns later with a score of 1-1
If both responses update the same state, the app shows the older score after the newer one.
From the user's perspective, the score moves backward.
This does not necessarily mean the football API returned incorrect data.
It may mean the responses arrived in the wrong order.
That is why the component aborts the previous request before starting a new one.
currentRequest.current?.abort()
const controller = new AbortController()
currentRequest.current = controller
The older request is no longer allowed to overwrite the newest result.
Do Not Delete Good Data Because One Refresh Failed
My first error handler replaced the match list with an empty array.
That was a bad decision.
A score that is fifteen seconds old is usually more useful than no score at all.
When a background refresh fails, keep the last successful data visible.
try {
const latestMatches = await loadMatches()
setMatches(latestMatches)
setError(null)
} catch {
setError(
"Updates are delayed. Showing the latest available scores."
)
}
The UI should communicate that the data may be stale, but it should not destroy useful information.
A live football app has more states than just loading, success, and error.
Initial loading
Fresh data
Refreshing
Temporarily stale data
Partial data
Provider unavailable
Recovered
Designing for these states makes the app feel much more stable.
Match Status Should Control Behavior
At first, I treated match status as a label.
<span>{match.status}</span>
But status is not only presentation data.
It should control the application.
For example:
// lib/match-status.ts
import type { FootballMatch } from "@/types/match"
export function isLive(
match: FootballMatch
): boolean {
return (
match.status === "live" ||
match.status === "halftime"
)
}
export function isFinished(
match: FootballMatch
): boolean {
return match.status === "finished"
}
export function canStillChange(
match: FootballMatch
): boolean {
return (
match.status === "scheduled" ||
match.status === "live" ||
match.status === "halftime"
)
}
These helpers can determine:
- Whether polling should continue
- Whether the minute should be shown
- Whether kickoff time should be displayed
- Whether the score is final
- Whether notifications can still be triggered
- Whether standings may need updating
As the app grows, you may need additional states such as:
Extra time
Penalties
Interrupted
Abandoned
Delayed
Awarded
Normalizing those states early prevents status checks from spreading throughout the codebase.
Group Matches by Competition
A flat list works when there are five matches.
It becomes difficult to read when there are fifty.
Group the matches by league or competition before rendering them.
// lib/group-matches.ts
import type { FootballMatch } from "@/types/match"
type CompetitionGroup = {
id: string
name: string
country: string | null
matches: FootballMatch[]
}
export function groupMatchesByCompetition(
matches: FootballMatch[]
): CompetitionGroup[] {
const groups = new Map<
string,
CompetitionGroup
>()
for (const match of matches) {
const competitionId =
match.competition.id
const existing =
groups.get(competitionId)
if (existing) {
existing.matches.push(match)
continue
}
groups.set(competitionId, {
id: competitionId,
name: match.competition.name,
country: match.competition.country,
matches: [match],
})
}
return Array.from(groups.values())
}
Then render each competition separately.
// components/match-list.tsx
import { groupMatchesByCompetition } from "@/lib/group-matches"
import type { FootballMatch } from "@/types/match"
import { MatchRow } from "@/components/match-row"
type MatchListProps = {
matches: FootballMatch[]
}
export function MatchList({
matches,
}: MatchListProps) {
const groups =
groupMatchesByCompetition(matches)
if (groups.length === 0) {
return (
<p>No matches are available.</p>
)
}
return (
<div>
{groups.map((group) => (
<section key={group.id}>
<header>
{group.country && (
<span>{group.country}</span>
)}
<h2>{group.name}</h2>
</header>
<div>
{group.matches.map((match) => (
<MatchRow
key={match.id}
match={match}
/>
))}
</div>
</section>
))}
</div>
)
}
The data structure now supports the interface instead of forcing every component to repeat the same transformation.
A Simple Match Row
Once the data is normalized, the UI becomes much easier to write.
// components/match-row.tsx
import type { FootballMatch } from "@/types/match"
import { isLive } from "@/lib/match-status"
type MatchRowProps = {
match: FootballMatch
}
export function MatchRow({
match,
}: MatchRowProps) {
return (
<article>
<div>
<span>{match.homeTeam.name}</span>
{match.homeTeam.logoUrl && (
<img
src={match.homeTeam.logoUrl}
alt=""
width={24}
height={24}
/>
)}
</div>
<div>
{match.status === "scheduled" ? (
<time dateTime={match.startsAt}>
{new Date(
match.startsAt
).toLocaleTimeString([], {
hour: "2-digit",
minute: "2-digit",
})}
</time>
) : (
<strong>
{match.score.home ?? 0}
{" - "}
{match.score.away ?? 0}
</strong>
)}
{isLive(match) && (
<span>
{match.minute
? `${match.minute}'`
: "LIVE"}
</span>
)}
</div>
<div>
{match.awayTeam.logoUrl && (
<img
src={match.awayTeam.logoUrl}
alt=""
width={24}
height={24}
/>
)}
<span>{match.awayTeam.name}</span>
</div>
</article>
)
}
The component does not know anything about the external provider.
It only understands the application's FootballMatch type.
Caching Does Not Mean Showing Old Scores for Minutes
Developers sometimes avoid caching live data because they assume a cache will make the app feel outdated.
That depends on the cache duration.
A short shared cache can reduce duplicate requests without creating a noticeable delay.
For example:
Browser refresh interval: 15 seconds
Server cache duration: 10 seconds
Stale-while-revalidate window: 20 seconds
Users still receive frequent updates.
But if hundreds of users request the same match data within a short period, the server can reuse the result.
Without shared caching:
500 users
500 external API requests
With a short server cache:
500 users
A much smaller number of external API requests
Live does not always mean uncached.
It often means briefly cached and frequently refreshed.
The Final Request Flow
After restructuring the application, one visit follows this flow:
User opens the football app
Server Component
Fetches today's matches
Uses a short shared cache
Maps the provider response
Renders the initial page
Browser
Displays matches immediately
Checks whether any match is live
If a match is live
Starts a refresh interval
Requests updates from the internal API route
Route Handler
Validates the date
Reuses recent shared data when possible
Calls the football provider when needed
Returns normalized match objects
Client Component
Cancels the previous request
Applies the newest result
Keeps old data if refreshing fails
Stops polling when all matches finish
Every layer now has a clear responsibility.
The Mental Model That Helped Me
The football provider supplies the raw data.
The server protects, caches, validates, and normalizes it.
The browser renders the data and requests updates only while they are useful.
That is the architecture I wish I had used from the beginning.
My first version was not wrong because it used polling.
It was wrong because polling was the entire architecture.
There was no protection against duplicate requests, stale responses, race conditions, provider changes, or finished matches that could no longer change.
What I Would Add Next
Once the live match flow is stable, the same foundation can support:
- Match detail pages
- Goals, cards, and substitutions
- Starting lineups
- League standings
- Team pages
- Player statistics
- Favorite teams
- Goal notifications
- Match search
- User time zones
- WebSocket updates
- Server-Sent Events
- Multiple sports
The important part is keeping the same data flow:
External provider
↓
Server-side client
↓
Mapping layer
↓
Application data functions
↓
Route Handler or Server Component
↓
User interface
When these boundaries remain clear, adding new football features becomes much easier.
Final Lesson
The visible part of a football app is simple.
Two teams.
One score.
One match clock.
The difficult part is making sure every user sees the newest correct version without generating unnecessary requests or exposing private credentials.
A demo only needs to display football data.
A real football product needs to manage:
- Live updates
- Shared caching
- Request ordering
- Match states
- Provider failures
- Stale data
- API security
- Response normalization
- Rendering performance
My app worked perfectly before matchday because it had never experienced matchday conditions.
The busy afternoon did not break the application.
It revealed the application I had actually built.
Have you ever built a live sports app? What was the first problem that only appeared after real users arrived?
Top comments (0)