RESTful API backend for NBA statistics application using ESPN APIs. Built with an API-first architecture where all data transformation and processing happens on the backend, providing clean, pre-processed data to the frontend.
Monorepo documentation index: ../docs/README.md.
- API-First Design: All data extraction, transformation, and caching centralized on backend
- Games Scoreboard: Get games for any date with live scores, status, and featured games
- Game Details: Detailed game information with boxscore, player statistics, and pre-calculated top performers
- Player Statistics: Season statistics with pre-calculated top players by category
- Team Information: Team details, leaders, recent games, and schedules
- Team Standings: Current NBA standings with pre-formatted display values
- Home Dashboard: Aggregated data for home page (top performers, season leaders)
- News Feed: NBA news from Twitter/X
- Caching: In-memory caching for improved performance and reduced API calls
- Node.js
- Express.js
- ESPN API (Scoreboard, Summary, Player Stats, Standings, Team Info)
- In-memory caching with Map-based cache
This backend follows an API-first design pattern:
- Data Fetching: Backend fetches raw data from ESPN APIs
- Data Transformation: All data extraction, flattening, and processing happens on backend
- Pre-calculation: Derived data (top performers, leaders, featured games) is pre-calculated
- Clean Responses: Frontend receives only the data it needs, in a clean, consistent format
- Caching: Responses are cached to reduce redundant API calls
- Simplified Frontend: Frontend only consumes clean data, no complex processing
- Better Performance: Calculations done once on backend, cached for multiple requests
- Maintainability: All data logic centralized in one place
- iOS-Ready: Clean API structure makes it easy to build mobile apps
- Consistency: All endpoints follow the same pattern
npm installQuick Setup:
-
Copy the example environment file:
cp .env.example .env
-
The
.envfile is already configured with default values for local development.
Manual Setup:
If you prefer to create .env manually, use the following content:
# Server Configuration
PORT=3000
NODE_ENV=development
# CORS Configuration
# For local development, use http://localhost:5173 (Vite default port)
CORS_ORIGIN=http://localhost:5173Environment Variables:
PORT: Server port (default: 3000)NODE_ENV: Environment (development/production)CORS_ORIGIN: Frontend URL for CORS (required for production)
Note:
- The
.envfile is gitignored and won't be committed to the repository - The project uses
dotenvto automatically load environment variables from.envfile - For production (Railway), set environment variables in the Railway dashboard instead of using
.env
POST /api/v1/notifications/registerstores Expo push tokens. With PostgreSQL configured, tokens persist in tablepush_tokens(runmigrations/003_create_push_tokens.sqlon your DB). Without a DB, tokens are kept in memory only (lost on restart; not shared across instances).DISABLE_PUSH_CRON=truedisables the minutely job that sends “close game” and “MVP GIS” alerts—useful until APNs/Expo delivery is verified.- Season defaults for standings / ESPN stats scraping:
config/seasonDefaults.jsor envNBA_STANDINGS_SEASON_YEAR,NBA_STANDINGS_SEASON_TYPE,NBA_ESPN_STATS_SEASON.
Optional but recommended for production: news v2, push tokens, and league calendar phase (league_seasons).
DATABASE_URL(orPGHOST/PGUSER/PGPASSWORD/PGDATABASE) — see.env.example. Railway public proxies (*.rlwy.net) use TLS; the pool appliessslautomatically viaconfig/pgConnectionOptions.js.- Migrations (from repo root
nba-stats-api/):npm run migrate— runs everymigrations/*.sqlin lexical order.npm run migrate:league-seasons— runs onlymigrations/004_create_league_seasons.sql.
league_seasons— one row withis_current = truedrivesseasonMeta/ postseason UI when present (see../docs/API_V1_SCHEMAS.md). Flip phase with SQLUPDATE(examples in the migration file comments).GET /api/v1/app/config— returnsleagueSeason(ESPN-shaped summary) ornullif no DB row / DB off.
Development:
npm run devProduction:
npm startServer runs on http://localhost:3000 (or PORT from .env)
Versioning: Prefer /api/v1/nba/... (wrapped { success, data, meta }). Examples below show the legacy /api/nba/... path for historical compatibility; replace the prefix with /api/v1 for new clients.
GET /api/nba/games/today?date=20251210&featured=true
Query Parameters:
date(optional): Date in YYYYMMDD format (defaults to today)featured(optional): Set totrueto get featured games separated from other games
Response (without featured):
{
"date": "2025-12-10",
"totalGames": 5,
"games": [
{
"gameId": "401809835",
"gameStatus": 3,
"gameStatusText": "Final",
"homeTeam": {...},
"awayTeam": {...}
}
]
}Response (with featured=true):
{
"date": "2025-12-10",
"totalGames": 5,
"games": [...],
"featured": [
{
"gameId": "401809835",
"featuredReason": "overtime",
...
}
],
"other": [...]
}Featured Game Reasons:
overtime: Games that went to overtimemarquee: Marquee matchups (e.g., GSW vs LAL)closest: Best game (closest score for completed games)live: Currently live games
GET /api/nba/games/:gameId
Response:
{
"gameId": "401809835",
"gameStatus": 3,
"homeTeam": {...},
"awayTeam": {...},
"boxscore": {
"teams": [
{
"teamName": "Oklahoma City Thunder",
"teamLogo": "...",
"starters": [...],
"bench": [...],
"topPerformers": {
"points": [
{
"name": "Player Name",
"stats": {"points": 30},
"teamName": "Oklahoma City Thunder",
"teamLogo": "...",
"teamAbbreviation": "OKC"
}
],
"rebounds": [...],
"assists": [...]
}
}
]
}
}Note: topPerformers includes team info (teamName, teamLogo, teamAbbreviation) for each player.
GET /api/nba/players/:playerId
GET /api/nba/players/:playerId/bio
GET /api/nba/players/:playerId/stats/current
GET /api/nba/players/:playerId/stats
Response:
{
"playerId": "123456",
"statistics": [
{
"season": "2025-26",
"stats": {...}
}
]
}Note: Seasons are returned from newest to oldest.
GET /api/nba/players/:playerId/stats/advanced
GET /api/nba/players/:playerId/gamelog
GET /api/nba/stats/players?season=2026|2&position=all-positions&limit=100&sort=offensive.avgPoints:desc
Query Parameters:
season: Season in format "YYYY|type" (e.g., "2026|2" for regular season, "2026|3" for postseason)position: Position filter (all-positions, guard, forward, center)page: Page number (default: 1)limit: Items per page (default: 50, max: 100)sort: Sort field (e.g., "offensive.avgPoints:desc")
Response:
{
"topPlayersByStat": {
"avgPoints": {
"players": [
{
"id": "123456",
"name": "Player Name",
"team": "Team Name",
"headshot": "...",
"stats": {
"avgPoints": {"value": "30.5", "displayValue": "30.5"}
}
}
]
},
"avgRebounds": {...},
"avgAssists": {...}
},
"metadata": {
"season": "2025-26",
"totalPlayers": 450
}
}Note: Returns only top 9 players per category, pre-calculated on backend.
GET /api/nba/teams/:teamAbbreviation
Response:
{
"team": {
"id": "1",
"name": "Warriors",
"abbreviation": "GSW",
"record": {
"wins": 24,
"losses": 1
}
},
"players": [
{
"id": "123456",
"name": "Player Name",
"position": "PG",
"stats": {...}
}
]
}GET /api/nba/teams/:teamAbbreviation/leaders
Response:
{
"offense": {
"points": {
"name": "Player Name",
"value": "30.5"
},
"assists": {...},
"rebounds": {...}
},
"defense": {
"steals": {...},
"blocks": {...}
}
}GET /api/nba/teams/:teamAbbreviation/recent-games?seasontype=2
Query Parameters:
seasontype: Season type (2 = Regular Season, 3 = Postseason)
Response:
{
"last5Games": [
{
"id": "401809835",
"date": "2025-12-10T00:00:00Z",
"homeTeam": {...},
"awayTeam": {...},
"won": true
}
],
"next3Games": [...]
}GET /api/nba/teams/:teamAbbreviation/schedule
GET /api/nba/standings?season=2026&seasonType=2
Query Parameters:
season: Season year (default: 2026)seasonType: Season type (2 = Regular Season, 3 = Postseason)
Response:
{
"seasonDisplayName": "2025-26 Regular Season",
"standings": {
"East": [
{
"teamName": "Boston Celtics",
"wins": 24,
"losses": 1,
"winPercent": 0.96,
"winPercentDisplay": ".960",
"gamesBehind": 0,
"gamesBehindDisplay": "-"
}
],
"West": [...]
}
}Note: winPercentDisplay and gamesBehindDisplay are pre-formatted for display.
GET /api/nba/home?date=20251210
Query Parameters:
date(optional): Date in YYYYMMDD format (defaults to today)
Response:
{
"todayTopPerformers": {
"points": [
{
"id": "123456",
"name": "Player Name",
"team": "Team Name",
"teamAbbreviation": "GSW",
"headshot": "...",
"points": 45
}
],
"rebounds": [...],
"assists": [...]
},
"seasonLeaders": {
"points": [
{
"id": "123456",
"name": "Player Name",
"team": "Team Name",
"headshot": "...",
"value": "34.7",
"statType": "avgPoints"
}
],
"rebounds": [...],
"assists": [...]
}
}GET /api/nba/news
Response:
{
"tweets": [
{
"id": "1234567890",
"text": "Tweet content...",
"author": "Shams Charania",
"authorHandle": "@ShamsCharania",
"avatar": "...",
"timestamp": "2025-12-10T12:00:00Z",
"images": [...],
"imageLinks": [...]
}
]
}Note: News is cached and refreshed every 5 minutes via cron job.
The API returns data in consistent, well-structured formats. See frontend data models for TypeScript-style JSDoc definitions:
gameModels.js- Game-related typesplayerModels.js- Player detail typesplayerStatsModels.js- Player stats typesstandingsModels.js- Standings typesteamModels.js- Team-related types
- Games: 5 seconds cache for live games, 5 minutes for completed games
- Player Stats: 5 minutes cache
- Team Data: 30 minutes cache
- Standings: 30 minutes cache
- News: 5 minutes cache (refreshed via cron)
-
Install Railway CLI (optional but recommended):
npm i -g @railway/cli
-
Deploy via Railway Dashboard:
- Go to railway.app
- Click "New Project"
- Select "Deploy from GitHub repo" (connect your GitHub account)
- Select the
nba-stats-apidirectory - Railway will automatically detect Node.js and deploy
-
Set Environment Variables in Railway:
CORS_ORIGIN: Your frontend URL (e.g.,https://your-frontend.vercel.app)NODE_ENV:production(optional, Railway sets this automatically)PORT: Railway automatically provides this (no need to set manually)
-
Railway build (Nixpacks + Puppeteer)
Nixpacks seespuppeteerinpackage.json(used byservices/newsService.jsfor some Nitter instances). By default it adds a longapt-get installfor GUI libraries and Chromium; on Railway that step often crawls (Ign:= mirror retries towardarchive.ubuntu.com).nixpacks.tomlsetsaptPkgs = []so the Dockerfile omits apt entirely for that layer, addschromiumvianixPkgs, and setsPUPPETEER_SKIP_CHROMIUM_DOWNLOAD+PUPPETEER_EXECUTABLE_PATH=/root/.nix-profile/bin/chromiumso Puppeteer uses the Nix-built browser (Nix binary cache is usually far faster than apt here).- Transient apt errors (if you ever re-enable apt): Redeploy on mirror blips.
- Override
PUPPETEER_EXECUTABLE_PATHin Railway Variables only if your image puts Chromium somewhere else.
-
Get Your API URL:
- Railway will provide a URL like:
https://your-app-name.up.railway.app - Use this URL in your frontend's
VITE_API_URLenvironment variable
- Railway will provide a URL like:
The backend can also be deployed to:
- Heroku
- Render
- AWS EC2
- DigitalOcean
- Any Node.js hosting platform
Important: Set the CORS_ORIGIN environment variable to your frontend URL in production.
nba-stats-api/
├── services/ # Service layer (data fetching & transformation)
│ ├── nbaService.js # ESPN API integration
│ ├── playerService.js # Player data transformation
│ ├── teamService.js # Team data transformation
│ ├── standingsService.js # Standings transformation
│ ├── espnScraperService.js # Player stats scraping
│ └── newsService.js # News/tweets service
├── utils/ # Utility functions
│ └── gameTransformer.js # Game data transformation
├── server.js # Express server & routes
└── package.json
ISC