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
| App | What it is | Dev port |
|---|---|---|
apps/statshub-web | The Next.js product site, replacing legacy | 3002 |
apps/statshub-admin | Internal admin surface over the same database | 3003 |
apps/statshub-api | The Go HTTP API | 8080 |
apps/statshub-expo | The React Native app, SDK 57 | 8142 (Metro) |
apps/statshub-docs | This site — Fumadocs on Next.js | 3001 |
apps/web-legacy | The previous Next.js app, still the port's reference | 3000 |
apps/csv-deduplicator | A standalone Flask tool, outside the workspace | — |
Packages
| Package | Consumers | What it is |
|---|---|---|
@statshub/ui-web | web, admin | Design-system primitives on Base UI |
@statshub/ui-native | expo | The React Native component kit |
statshub-api-client | web, expo | Platform-neutral JSON transport and typed HTTP errors |
statshub-api-contract | tooling | Records 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, everywhereTurbo 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.