StatsHub Docs

Architecture

What is in this monorepo, which app owns what, and why the workspace is shaped the way it is.

A Turborepo workspace on Bun. apps/* are deployables, packages/* are code more than one deployable imports.

A workspace's directory and its package name are the same string, prefixed statshub-: apps/statshub-web is the package statshub-web. Having the two match means a name in an import, a path in a Dockerfile and a --filter on the command line are all the same token, so none of them can drift from the others.

Two packages predate the rule and are still scoped — @statshub/ui-web and @statshub/ui-native — along with apps/web-legacy, which is being retired.

Apps

AppWhat it isDev port
apps/statshub-webThe Next.js product site, replacing legacy3002
apps/statshub-adminInternal admin surface over the same database3003
apps/statshub-apiThe Go HTTP API8080
apps/statshub-expoThe React Native app, SDK 578142 (Metro)
apps/statshub-docsThis site — Fumadocs on Next.js3001
apps/web-legacyThe previous Next.js app, still the port's reference3000
apps/csv-deduplicatorA standalone Flask tool, outside the workspace

Packages

PackageConsumersWhat it is
@statshub/ui-webweb, adminDesign-system primitives on Base UI
@statshub/ui-nativeexpoThe React Native component kit
statshub-api-clientweb, expoPlatform-neutral JSON transport and typed HTTP errors
statshub-api-contracttoolingRecords API responses from legacy, replays them against api

Three decisions worth knowing

There are two UI kits, and they cannot merge

expo is React Native; web and admin target the DOM; there is no react-native-web in this repo. A component cannot cross that line, so the kits are parallel rather than shared. Transport logic does cross through statshub-api-client; React components and cache policy do not. See Packages for the full split.

Only the Go API talks to Postgres

api declares the schema once, in internal/schema, and every other app reads it over HTTP. It was not always so: admin carried a Drizzle schema and a postgres client in statshub-db, and web a third declaration in its own src/db. Both are gone — admin proxies /api/* to the Go service and keeps only the response types its components read.

The Go port is proved, not asserted

api is a route-for-route port of the 218 Next.js route files in apps/web-legacy/src/pages/api, and the response bytes clients depend on are enforced by statshub-api-contract: goldens recorded from legacy, replayed against Go. A test that imports either implementation dies with it; only a test that speaks HTTP to both can compare them.

Running things

bun install          # once, from the root
bun run dev          # every server except legacy
bun run dev:web      # or dev:admin, dev:docs, dev:expo, dev:api
bun run check        # lint + typecheck + test, everywhere

Turbo infers task order from the dependency graph, so a package a consumer needs is built first without being told.

Deployment

Every deployable has apps/<name>/Dockerfile, built with the repository root as the context and a service in docker-compose.yml. The first stage runs turbo prune statshub-<name> --docker, which cuts the workspace down to that app and the packages it actually imports — without it, every web image would also install the Expo client's native dependency tree.

NEXT_PUBLIC_* is a build-time value, twice over

It is inlined into the client bundle, so it must be passed as a Docker build arg rather than runtime environment. It must ALSO be listed in turbo.json's build.env, because that list is the cache key — a variable your build reads that is not in it means Turbo serves a stale build when the value changes.

On this page