I wanted a small but realistic way to explore WNBA data without turning the app into a toy demo. The goal of this project was to build a clean, production-style live score tracker that shows how a developer can use Sportmicro as a basketball data provider in a real Next.js app.
What made this interesting to me was not just rendering scores. Sports data is messy in practice: live matches can be empty, standings can be partial, and upstream responses can change shape over time. So I treated the app as a boundary around provider data rather than as a generic dashboard. That mindset shaped the whole implementation.
If you want to skim the source while reading, View the repository.
What I built and why I kept it narrow
This project is intentionally focused on three Sportmicro endpoints:
/matches-live/standings/player-projections
Those three calls give the app enough variety to cover the main patterns you run into with sports APIs: current game status, a league table snapshot, and forecast-style data. That mix was enough to demonstrate the integration without hiding it behind a lot of routing, state management, or unrelated UI.
The app is built with Next.js, React, TypeScript, and Tailwind CSS. In the repository, the page itself is very small:
-
app/page.tsxrenders a singleLiveScoreTrackercomponent. -
app/layout.tsxprovides metadata and the root document shell. - Most of the behavior lives in
components/live-score-tracker.tsx. - API access and data shaping live under
lib/sportmicro/.
That structure is useful because it keeps the UI thin. The page doesn’t know how to authenticate, what endpoint paths exist, or how to normalize provider payloads. It only knows how to display the models it receives.
The architecture: one boundary for Sportmicro, one boundary for the UI
The core architectural choice in this repo is the separation between transport, mapping, and rendering.
1) Transport lives in a dedicated client
lib/sportmicro/client.ts contains a SportmicroClient that does three things:
- reads
SPORTMICRO_API_KEYfrom the environment - sends it as a bearer token
- throws a structured error when a request fails
That means the rest of the app never touches raw authentication logic. The client also uses cache: 'no-store', which fits the live-data use case because the app is meant to request fresh snapshots rather than serve cached responses.
The client exposes a small error shape too:
export class SportmicroApiError extends Error {
status: number;
details?: unknown;
// ...
}
That matters because the UI can treat API problems as real application states, not generic failures.
2) Endpoint wrappers stay tiny
lib/sportmicro/index.ts wraps the actual endpoint paths in descriptive functions:
fetchLiveMatches()fetchWNBAStandings()fetchPlayerProjections()
This is a good pattern because the rest of the app imports intent, not URLs. For example, the standings call hardcodes type: 'total', while the other optional filters are left open for future extension. That tells you where the app is opinionated and where it is intentionally flexible.
3) Mappers normalize provider data before rendering
lib/sportmicro/mappers.ts converts raw Sportmicro objects into UI-ready shapes:
toLiveScoreCardtoStandingRowtoProjectionRow
This is one of the strongest decisions in the project. The UI doesn’t render provider objects directly. Instead, it uses compact structures like LiveScoreCard, StandingsRow, and ProjectionRow.
That helps in two ways:
- the component tree stays readable
- the app can display fallback values like
—when a field is missing
I like that the mapper handles both names and IDs for teams and players. For example, if a human-readable name is missing, the app can still show the underlying identifier instead of failing silently. That’s the kind of small resilience you want in a sports data UI.
How the implementation flows from fetch to screen
The app flow is simple, but each step is deliberate.
components/live-score-tracker.tsx is the main screen. It does a concurrent fetch with Promise.all, then maps the results into presentation-friendly arrays:
const [matchesData, standingsData, projectionData] = await Promise.all([
fetchLiveMatches(),
fetchWNBAStandings(),
fetchPlayerProjections()
]);
I chose to keep these requests together because the page is designed as a snapshot view. It doesn’t need each section to load independently. The result is easier to read and reason about: either the dashboard data arrives together, or the UI shows an error state.
The component then renders three areas:
- live games
- standings
- player projections
Each section is wrapped in a shared SectionCard component, which keeps the visual treatment consistent. That matters less for aesthetics than for maintainability. When a dashboard has multiple sections, consistency reduces the amount of repeated markup and makes future changes easier.
How the UI handles real-world data states
A sports app is only believable if it handles the awkward states honestly.
This project does that in three ways:
Error state
If the Sportmicro request fails, the component catches the exception and shows ErrorBanner. The banner is explicit about the source of the problem:
- “Unable to load Sportmicro data”
- followed by the error message
That’s a better experience than a blank screen because it tells the user the issue is upstream, not just missing UI.
Empty state
Each section has its own empty-state treatment. If there are no live games, no standings rows, or no projections, the app shows a local explanatory card. This is important because “no data” is not the same as “broken.”
Fallback values
The mapper layer converts nullish fields into —. That keeps the display stable even when some parts of a record are missing. In practice, that’s one of the easiest ways to make an API-driven UI feel trustworthy.
One subtle but valuable detail: the live score cards show both score and event phase. That makes the snapshot useful even when a game is not actively changing. You get status context, not just a number.
Project structure at a glance
Here’s the compact shape of the repository:
app/
layout.tsx
page.tsx
globals.css
components/
live-score-tracker.tsx
error-banner.tsx
section-card.tsx
lib/
sportmicro/
client.ts
index.ts
mappers.ts
types.ts
tests/
client.test.ts
mappers.test.ts
page-content.test.ts
I find this tree useful because it reflects the boundaries cleanly:
-
app/is for composition -
components/is for UI building blocks -
lib/sportmicro/is the provider integration layer -
tests/is where the behavior gets locked down
That separation makes the project easy to extend without creating a tangle of cross-dependencies.
Challenges and trade-offs
I didn’t treat this project as a place to solve every sports-data problem. The trade-offs were mostly design constraints:
- The app uses server-fetched snapshots rather than a persistent realtime stream.
- The UI is scoped to a few Sportmicro endpoints instead of claiming broad WNBA coverage.
- Response types are modeled in TypeScript, but the repository does not add runtime schema validation.
- Query filters exist in the client wrapper for some endpoints, but the current screen keeps the experience simple and focused.
Those constraints are sensible for an educational integration example. They keep the code honest about what it does today, while leaving room for growth.
Local setup and what the repo supports
The repository does support local setup, and the README makes the expected workflow clear.
You need:
- Node.js 18 or newer
- a Sportmicro API key
The setup flow is straightforward:
- install dependencies with
npm install - create a local
.env.local - set
SPORTMICRO_API_KEY=your_key_here - run
npm run dev
The project also includes scripts for:
npm run buildnpm testnpm run typecheck
I appreciated that there is a .env.example file in the repo. That’s a small but practical signal that the app expects environment-based configuration and wants to make setup repeatable.
What I would improve next
If I were extending this further, I’d focus on a few natural follow-ups.
Add query controls
The client already supports optional parameters like leagueId, seasonId, and matchId. A next step would be to expose those in the UI so users can narrow the data source instead of always rendering a broad snapshot.
Add runtime validation
The types are helpful, but TypeScript alone doesn’t validate live API responses. A runtime schema layer would make the integration more defensive when provider data changes.
Add live refresh behavior
Right now, the app renders a fresh server snapshot. Polling or another refresh mechanism would make it more suitable for active game monitoring.
Expand the endpoint coverage
The current scope is intentionally compact. More Sportmicro basketball endpoints could deepen the app without changing its architecture.
Takeaway
The main lesson from this build is that a useful sports app is really a good integration boundary.
By keeping Sportmicro access inside a small client, normalizing data before it reaches React, and treating empty/error states as first-class UI conditions, the project stays understandable even though it is driven by external live data.
That pattern is reusable beyond WNBA scores. If you’re building with a sports data API, a clean boundary like this will save you from coupling the UI too tightly to provider-specific payloads.
The repo is small on purpose, but that’s what makes it instructive: it shows the shape of a real integration without pretending the hard parts don’t exist.
Top comments (0)