StatsHub Docs

Data

Every read goes through the Go API — how server-api.ts is shaped, what SWR does on the client, and why almost nothing in the dashboard is prerendered.

There is no database client in this app and no connection string in its environment. Every read is an HTTP call to the API, and there are exactly two ways to make one.

On the server: server-api.ts

src/lib/server-api.ts is the server-side adapter — it binds the shared statshub-api-client transport to API_ORIGIN and Next's data cache. import "server-only" keeps the adapter out of client bundles.

const team = await getPublicApi<Team>(`/team/${id}`, { revalidate: 3600 });

Three behaviours worth knowing before you call it:

  • undefined and null query values are dropped, so an optional filter does not become ?league=undefined.
  • 404 returns null, not a throw. A missing entity is an ordinary answer; the caller decides whether that is notFound() or an empty state.
  • Any other non-2xx throws, with the status and path in the message. That reaches the nearest error.tsx rather than rendering half a page.

revalidate defaults to 300 seconds and is what makes a route cacheable. Passing it is a decision about staleness — a fixture list wants seconds, a team's badge wants an hour.

On the client: SWR

Anything that has to react to a filter the user just changed is fetched with SWR, configured once in src/components/layout/providers.tsx:

{
  dedupingInterval: 24 * 60 * 60 * 1000,
  revalidateIfStale: true,
  revalidateOnFocus: true,
  revalidateOnReconnect: true,
}

That deduping interval is a day, which is unusual and deliberate: the boards re-render constantly as filters change, and without it every re-render of a shared hook is another request. Staleness is handled by the three revalidate* flags instead — on focus, on reconnect, and when a key is known stale — rather than by a timer.

There is no React Query here

Where state lives is the rule across apps: a cache owns server state. On web that cache is SWR; on expo it is React Query. Mixing them in one app means two caches with two copies of the same row.

SWR's fetcher and postFetcher use the same shared transport as Server Components and Expo. SWR still owns the web cache; sharing a transport does not create a second cache.

What is prerendered

Almost nothing, and the reason is one line in a layout.

src/lib/static-params.ts generates params for the entity routes by reading the sitemaps the API already serves — /api/sitemap-teams.xml and friends. That was chosen over a "list every id" endpoint per route because the sitemap is already the answer to "which of these pages do we want crawled": it filters to visible tournaments, and the fixtures one is scoped to finished matches from the last 60 days.

It deliberately does three things by halves:

  • It does not prerender everything. sitemap-players.xml is six figures of URLs. Each route takes the first STATIC_PARAMS_LIMIT (default 250); the rest render on first request, which is the App Router's own default for a param outside the generated set.
  • It does not fail the build. docker build has no API on its network. An unreachable API means zero prerendered paths and a warning, not a broken image.
  • It does not, on its own, make these routes static. Everything under (dashboard) inherits a layout that reads the statshub_locale cookie, so the whole segment renders per request and no generateStaticParams in it currently produces a prerendered page.

Moving the locale out of that layout, or turning on cacheComponents, is what switches them on. Until then generateStaticParams is a warm cache waiting for a change elsewhere.

Images

Served through Next's own optimizer with sharp, in AVIF and WebP, cached 30 days at the optimizer and edge-cached by Cloudflare in front. The origin is R2.

Every remote host has to be listed in images.remotePatterns in next.config.js — a new CDN that is not there renders a broken image with a 400 from /_next/image, which reads like a bad URL and is not.

On this page