A responsive data dashboard showcasing all 30 NBA franchises — built as a front-end take-home project in Next.js 16 with a focus on aesthetic polish, accessibility, and thoughtful architecture.
Live demo: nba-teams-dashboard.vercel.app
| Dark | Light |
|---|---|
![]() |
![]() |
![]() |
![]() |
- Editorial visual design — per-team accent colors pulled from the API, Space Grotesk display type, and a dark mode that's the default (not an afterthought)
- Zero-flash theme toggle — a blocking inline script sets the theme before first paint, so reloading never shows a white flash in dark mode
- Real React 19 concurrent features — search uses
useDeferredValueso typing never blocks rendering, even on a throttled CPU - Server Components by default — data fetching lives in
asyncserver components, client components only where interactivity demands them
- All 30 NBA teams in a responsive grid (1 → 2 → 3 → 4 columns across breakpoints)
- Live client-side search by name, city, or abbreviation
- Dedicated team detail pages with badge, arena, capacity, founding year, description, and roster
- Skeleton loading, empty, and error states at both the list and detail levels
- Error boundaries with a working "Try again" button that refetches
- Dark / light mode toggle persisted to
localStorage, respectsprefers-color-schemeon first visit - Staggered card reveal animations and hover-lift via Framer Motion
- Fully keyboard navigable with visible focus rings, ARIA live regions on search, and semantic landmarks
- Responsive from 375px up, tested across mobile, tablet, and desktop breakpoints
Requires Node.js 20 or higher.
git clone https://github.com/akallam04/nba-dashboard.git
cd nba-dashboard
npm install
npm run devOpen http://localhost:3000. No environment variables or API keys required — the project uses TheSportsDB's free public tier.
For a production build:
npm run build
npm startThe App Router (stable since Next.js 13.4) co-locates data fetching with the components that consume it via async Server Components. That eliminates the prop-drilling and loading-waterfall problems inherent to getServerSideProps. Each route segment owns its own loading.tsx and error.tsx, which Next wraps in <Suspense> and <ErrorBoundary> automatically — so route-level loading and error UI cost almost no code.
| Component | Rendering | Why |
|---|---|---|
app/page.tsx |
Server | Reads all 30 teams from local snapshot at request time, no client JS needed for initial render |
app/teams/[id]/page.tsx |
Server | Reads team from local snapshot, fetches roster server-side |
TeamGrid |
Client | Owns search state, needs useState and useDeferredValue |
TeamCard |
Client | Framer Motion animations require browser APIs |
ThemeToggle |
Client | Reads and writes localStorage, mutates the DOM |
Deliberately minimal — no Redux, no Zustand, no Context. The only state in the app is:
- Search query (
useStateinsideTeamGrid, filtered withuseDeferredValue) - Theme preference (
localStorageplus adata-themeattribute on the<html>element)
Adding a favorites feature would be the natural trigger for introducing persisted local state, and a team comparison view would justify a lightweight store.
The standard pitfall with dark mode is the brief white flash on page load before JavaScript runs and applies the user's saved preference. The fix is a blocking synchronous <script> in <head> that runs before paint — it reads localStorage, falls back to prefers-color-scheme, and sets data-theme on <html> before any styles apply. CSS custom properties keyed on [data-theme] handle the actual color switching from there, so navigation never re-flashes.
Three error entry points are wired up:
app/error.tsxcatches list page fetch failuresapp/teams/[id]/error.tsxcatches detail page fetch failuresapp/teams/[id]/not-found.tsxrenders whennotFound()is thrown for an invalid team id
The dashboard reads all team data from a committed JSON snapshot at src/data/nba-teams.json, and serves all team badges from public/team-badges/ — no third-party CDN dependencies for the core experience. This is a deliberate resilience decision rather than a workaround.
The original implementation used TheSportsDB's free public API for both list (search_all_teams.php) and detail (lookupteam.php) data. Both endpoints have since become unreliable on the free tier — the list endpoint silently caps at 10 results, and individual team lookups have started returning unrelated data. Rather than gate the project behind a paid API key, I snapshotted the full 30-team dataset, downloaded the badge images into the repo, and made the dashboard self-contained for the parts users see most.
A live API call still exists for the optional roster section on team detail pages. When the API returns players, they render under "Featured Players." When it returns nothing, the section is hidden — the rest of the page is unaffected.
The trade-off is that team metadata (arena, founding year, badge image) is point-in-time. Refreshing the snapshot is a small script away. For a portfolio project that should never serve a degraded experience to a recruiter, the trade is worth it.
src/
├── app/
│ ├── layout.tsx # Root layout: fonts, theme script, header
│ ├── page.tsx # List page (Server Component)
│ ├── loading.tsx # Route-level skeleton
│ ├── error.tsx # Route-level error boundary
│ ├── globals.css # Tailwind + theme CSS variables
│ └── teams/[id]/
│ ├── page.tsx # Detail page (Server Component)
│ ├── loading.tsx
│ └── error.tsx
├── components/
│ ├── Button.tsx # Reusable — variants + sizes
│ ├── LoadingIndicator.tsx # Spinner + skeleton variants
│ ├── TeamCard.tsx # Per-team accent color + motion
│ ├── TeamGrid.tsx # Client: search state + filtered render
│ ├── PlayerCard.tsx
│ ├── SearchInput.tsx # Controlled input + ARIA live region
│ ├── EmptyState.tsx
│ ├── ErrorState.tsx
│ ├── ThemeToggle.tsx
│ └── Header.tsx
├── lib/
│ ├── api.ts # Typed fetch wrappers
│ └── utils.ts # cn() + color/URL helpers
└── types/
└── index.ts # Team, Player, API response types
- Team metadata is a committed snapshot. Arena names, founding years, and badge images are point-in-time as of the last snapshot refresh. They do not auto-update.
- Roster data depends on an external free API tier. The "Featured Players" section on detail pages calls TheSportsDB's
lookup_all_players.phpendpoint, which has uneven availability across teams on the free tier. Where a team returns no players, the section is hidden gracefully rather than rendering empty. - No automated tests yet. Unit and end-to-end tests are listed in the roadmap.
- No pagination. With 30 NBA franchises the list fits on one screen; pagination would only matter at league-wide player scale.
Things I'd tackle next with more time:
- Unit tests with Vitest + React Testing Library, targeting
TeamGridsearch logic and the theme toggle's localStorage handling - End-to-end tests with Playwright for the list → search → detail → back flow
- Favorites feature persisted to localStorage, with starred teams pinned to the top of the grid
- Team comparison view for side-by-side stats
- Conference / division filter chips alongside search
- Dynamic OG images per team via
opengraph-image.tsx - GitHub Actions CI: lint + typecheck + build on every PR
- Full WCAG 2.1 AA audit with axe-core
- Lighthouse 90+ across Performance, Accessibility, Best Practices, and SEO
MIT



