nbb-stats
v0.3.0
Published
A considerate server-side TypeScript client and React component kit for the NBB/Basketballstats JSON feeds.
Maintainers
Readme
NBB-Stats
A server-side TypeScript/JavaScript client and optional React component kit for the NBB Database JSON feeds.
Configure your club once by name, then fetch games, teams, competitions, standings, locations, clubs, and game statistics through a persistent cache:
import { NBBStats } from "nbb-stats";
const nbb = await NBBStats.forClub({
club: { name: "D.S.B.V. Punch", city: "Delft" },
contact: "[email protected]",
cacheWarm: { teamSeasons: 10 },
});
const games = await nbb.games();
const teams = await nbb.teams();
const history = await nbb.teamHistory();NBB-Stats is an independent open-source client and is not an official product of Basketball Nederland or Basketballstats.nl. It uses the JSON feeds documented at db.basketball.nl. Basketball Nederland supplies current competition data to that database, while its documentation and historical data are curated by Jaap Voets.
Responsible defaults
This package is designed to make the considerate integration the easiest integration:
- JSON responses only. It never requests the expensive HTML standings/graph page.
- At least 15 seconds between upstream request starts, including concurrent requests.
- One shared queue covers both the compact API and all-season JSON overview hosts.
- Atomic queue coordination between processes that share SQLite, SQL, or Redis.
- Persistent response caching and identical-request coalescing.
- Historical seasons are stored permanently.
- Current data becomes eligible for refresh only after the source import windows: 00:30 daily and 17:00 on weekends, Europe/Amsterdam.
- Expired data is served stale while one process refreshes it, and stale data is retained when the source is temporarily unavailable.
- No automatic retries; three consecutive failures open a five-minute circuit breaker.
- A
cache-onlymode guarantees crawlers cannot create upstream traffic.
The core client is deliberately server-only. Do not call the NBB feeds directly from browser components.
Install
Install from the npm registry with your preferred package manager:
npm install nbb-stats
pnpm add nbb-stats
yarn add nbb-stats
bun add nbb-statsThe package requires Node.js 22.5 or newer because its zero-dependency default cache uses Node's built-in SQLite driver.
Configure a club without database IDs
NBBStats.forClub() resolves the internal database ID from the cached club index. Matching ignores capitalization, punctuation and spacing. Add the city when a name could be ambiguous:
const nbb = await NBBStats.forClub({
club: { name: "D.S.B.V. Punch", city: "Delft" },
contact: "https://punch-basketball.nl/contact",
});An association club number is also accepted through club: { number: process.env.NBB_CLUB_NUMBER! }. The low-level new NBBStats({ clubId: 57 }) constructor remains available for existing integrations, but normal cache setup does not require callers to store club, team, or competition IDs.
Seasons use YYYY-YYYY, for example 2026-2027.
Common calls
// Configured club, inferred current season
await nbb.games();
await nbb.teams();
await nbb.competitions();
await nbb.club();
// One resource
await nbb.game(540825);
await nbb.statistics(540825);
await nbb.location(555);
// Explicit team/competition lookups
await nbb.teamGames(251, { season: "2017-2018" });
await nbb.competitionGames(4180);
await nbb.standings(4180);
await nbb.standingsWithHistory(4180); // W-L-D plus JSON-derived graph frames
// All known seasons in one cached JSON-output overview request.
await nbb.teamHistory();
await nbb.teamCompetitions(251, { season: "2017-2018" });
// Follow every cup stage, including rounds after this team is eliminated.
await nbb.cupTournament(251, { season: "2017-2018" });
// W-L-D requires the standings JSON plus competition games JSON.
// The second cold miss is still queued 15 seconds later.
await nbb.standingsWithRecords(4180);
// Official optional filters remain available.
await nbb.games({ daysBack: 3, daysAhead: 14 });
await nbb.locations({ includeAway: true });
await nbb.competitions({ all: true });game() first checks the already-cached current club schedule. It only requests the specific-game JSON URL when the game is not in that snapshot.
Historical teams, competitions, and cups
teamHistory() uses Basketballstats' _output=JSON club overview to retrieve all known team, season, and competition mappings in one request. You can filter the cached aggregate locally:
const allSeasons = await nbb.teamHistory();
const recent = await nbb.teamHistory({
seasons: ["2026-2027", "2025-2026"],
});teamCompetitions() keeps separate league, cup, and half-season registrations. cupTournament() follows the cup registrations of advancing teams to include every later stage, even after the originally selected team loses:
const competitions = await nbb.teamCompetitions(251, {
season: "2017-2018",
});
const bracket = await nbb.cupTournament(251, {
season: "2017-2018",
});Standings history without HTML
standingsWithHistory() combines the standings and competition-game JSON feeds. It calculates W-L-D and game-week position snapshots locally, then aligns the final frame with the official JSON positions so federation tie-breaks and penalties remain authoritative.
const standings = await nbb.standingsWithHistory(4180, {
season: "2026-2027",
});Refresh modes
Every data method accepts a refresh option:
await nbb.games({ refresh: "background" }); // default: stale-while-revalidate
await nbb.games({ refresh: "wait" }); // wait only when the entry is stale
await nbb.games({ refresh: "force" }); // deliberate manual refetch
await nbb.games({ refresh: "cache-only" }); // never contact upstreamUse force sparingly. It still obeys the shared 15-second queue, but bypasses the smart freshness window.
Prewarm after source imports
The source normally imports standings at 23:10 and games at 23:20 on weekdays. On weekends a second run starts at 15:40/15:50. The default cache boundaries allow roughly one hour for those jobs to finish.
To keep every discovered team and its latest ten seasons ready for team pages, declare the cache profile once:
const nbb = await NBBStats.forClub({
club: { name: "D.S.B.V. Punch", city: "Delft" },
contact: "[email protected]",
cacheWarm: { teamSeasons: 10 },
sqlite: { filename: "./data/nbb-cache.sqlite" },
});
const report = await nbb.syncClub();
console.log(report);The profile means the latest ten known seasons of every logical team, grouped by its normalized name. That includes archived teams whose latest season was years ago. syncClub() discovers all team/season registrations from the all-season overview and resolves each season's current database IDs, leagues, half-seasons, cups, games and standings internally. No list of team or competition IDs is required. It warms:
- the exact team-specific games dataset used by a team page;
- every league/phase registration and its standings plus JSON-derived history;
- every cup registration and the complete bracket, including stages after the club team is eliminated.
Advanced users can limit the page datasets while keeping automatic discovery:
cacheWarm: {
teamSeasons: {
count: 10,
include: ["games", "standings"], // omit cup bracket discovery
},
}Run syncClub() from your server scheduler at 00:30 every day and at 17:00 on Saturday/Sunday in Europe/Amsterdam. The first historical fill is intentionally slow because every cold upstream request keeps the global 15-second spacing. Run it as a background maintenance job, never in a page request. After that first fill, historical-season responses remain permanent cache hits and current-season responses refresh only after the source import windows.
Calling the job again in the same refresh window sends no new requests. The library does not start a hidden timer; cron, a systemd timer, a Docker scheduler, or your platform scheduler remains in control. Use a persistent SQLite/SQL cache or a Redis instance configured not to evict these keys if the data must remain available across restarts.
You can inspect the next boundary with nbb.nextRefreshAt().
Cache backends
All backends implement the same persistent response cache, refresh locks, and cross-process upstream queue. Choose one backend per deployment and share it across application instances.
SQLite (default)
No database setup or dependency is required:
const nbb = new NBBStats({
clubId: 57,
contact: "https://example.nl/contact",
sqlite: { filename: "./data/nbb-cache.sqlite" },
});The default location is ./.nbb-stats/cache.sqlite. Mount that directory on persistent storage when using Docker.
SQL: PostgreSQL
NBB-Stats does not own or close your pool. Install your normal driver (npm install pg) and pass it through the small adapter:
import { Pool } from "pg";
import { NBBStats, postgresDriver, SqlCache } from "nbb-stats";
const pool = new Pool({ connectionString: process.env.DATABASE_URL });
const cache = new SqlCache({
dialect: "postgres",
driver: postgresDriver(pool),
});
const nbb = new NBBStats({ clubId: 57, cache });SQL: MySQL
import mysql from "mysql2/promise";
import { mysqlDriver, NBBStats, SqlCache } from "nbb-stats";
const pool = mysql.createPool(process.env.DATABASE_URL!);
const cache = new SqlCache({
dialect: "mysql",
driver: mysqlDriver(pool),
});
const nbb = new NBBStats({ clubId: 57, cache });The SQL user needs permission to create the small nbb_stats_cache table on first use. You can override tableName if needed.
Redis
Install the official client (npm install redis). NBB-Stats connects lazily:
import { NBBStats, RedisCache } from "nbb-stats";
const nbb = new NBBStats({
clubId: 57,
cache: new RedisCache({ url: process.env.REDIS_URL! }),
});You may instead pass an existing node-redis compatible client.
Block crawlers from upstream traffic
Use both layers:
- Disallow deep historical/team/game routes in
robots.txtand addnoindex, nofollowwhere appropriate. - Treat any crawler request that still arrives as cache-only.
import { requestOptionsForUserAgent } from "nbb-stats";
const safe = requestOptionsForUserAgent(request.headers.get("user-agent"));
const games = await nbb.games(safe);Known crawlers receive prewarmed data. A cold crawler miss throws NbbCacheMissError without sending an upstream request. Catch it and return an empty/not-found response. This is a backstop for crawler user agents; infrastructure-level bot controls are still recommended because malicious bots can lie about their identity.
React components
The optional components are presentation-only. They accept already-fetched data and never make network requests:
import {
CupBracket,
ProgressiveGameList,
StandingsHistoryChart,
StandingsPanel,
} from "nbb-stats/react";
import "nbb-stats/react/styles.css";
export function Schedule({ games }) {
return (
<ProgressiveGameList
games={games}
initialCount={3}
nextCount={7}
locale="nl"
gameHref={(game) => `/games/${game.season}/${game.id}`}
teamHref={(team, game) => `/teams/${game?.season}/${team.id}`}
/>
);
}Included components:
GameCard,GameList, andProgressiveGameList(3 → 7 → all)StandingsTable,StandingsBarChart, andStandingsPanel- Animated
StandingsHistoryChartwith hover tooltips and playback controls - Stage-selecting
CupBracketwithout sideways scrolling
The CSS is responsive, has no scrolling containers, and follows .dark or [data-theme="dark"]. Override the --nbb-* CSS variables to match your site. Pass standings.history from standingsWithHistory() to the history chart; the client never retrieves the expensive HTML graph page.
Raw documented JSON
For fields not normalized yet, every model includes raw. You can also use the escape hatch for one of the documented JSON scripts:
await nbb.raw("wedstrijd.pl", {
clb_ID: 57,
seizoen: "2026-2027",
});Only club.pl, stand.pl, wedstrijd.pl, locatie.pl, competities.pl, team.pl, and stats.pl are accepted. Arbitrary and HTML URLs are rejected.
Development
npm install
npm run checkSee the official JSON documentation for source field names and filters. Contributions that add normalization should preserve the raw response and the traffic-safety guarantees.
License
MIT. Basketballstats/NBB data, names, and logos remain subject to their respective owners' terms.
