How apps find each other
One app's address is never written down in another. `appUrl("web", "/prop-screener")` reads the port from that app's own dev script, and an environment variable moves it.
Four apps run side by side, and they link to each other: the docs link to the web app, while the web app proxies the legacy app and reads the Go API. Every one of those is a URL that belongs to a different workspace.
The rule is that no app writes down another app's address. Ask the registry.
import { appOrigin, appUrl } from "statshub-tooling/apps";
appOrigin("api"); // http://localhost:8080
appUrl("web", "/prop-screener"); // http://localhost:3002/prop-screener| Id | App | Environment variable |
|---|---|---|
web | statshub-web | WEB_ORIGIN |
api | statshub-api | API_ORIGIN |
docs | statshub-docs | DOCS_ORIGIN |
legacy | legacy | LEGACY_ORIGIN |
The Expo app is absent on purpose: Metro serves a bundle over exp://, not a
page anything here can link to.
The port is read, not listed
Each app already states its port in its own dev script, and that is the only
copy that cannot drift. appPort reads it from there — the --port flag for a
Next app, the number handed to free-port.sh for one on Next's default, and
${PORT:-8080} inside scripts/dev.sh for the Go API, which has no framework
flag to carry it.
So moving an app is still a one-line change in that app, and nothing else has to be told.
Two ways to override, and they compose
DEV_HOST moves every app at once
Host only, ports untouched. DEV_HOST=box.tailb9cb87.ts.net makes all four
resolve to that machine. You rarely need it: when the box is on a tailnet,
that address is already the default in development.
<APP>_ORIGIN moves one, wholesale
Scheme, host and port. WEB_ORIGIN=https://statshub.com points everything
that links to web at production without touching anything that links to
the others. Set in a .env, a Docker build arg, or the shell.
A new variable has to be declared in turbo.json
Turbo runs tasks in strict environment mode, so a variable it has not been told
about never reaches the command — WEB_ORIGIN=… bun run dev:web sets nothing,
silently, and the app falls back to the default port. All five live in
globalEnv, which both passes them through and puts them in the build cache
key. The cache key matters as much as the passthrough: these values are inlined
into the build output, so a build that ignored them would happily serve a cached
bundle with the wrong URLs baked in.
localhost is wrong more often than it looks
The dev box here is a remote server reached over Tailscale. A link to
localhost:3002 sends the reader to their own machine, where nothing is
serving. That is why a hardcoded http://localhost:... in a doc or a script
is a bug rather than a shortcut.
The default is the tailnet, not localhost
In development, an app whose origin nobody has named resolves to this
machine's tailnet address rather than localhost — so a reader on their own
laptop follows a cross-app link and arrives, without having been told to
export anything first.
DEV_HOST still wins, and so does any <APP>_ORIGIN. A machine that is not
on a tailnet, or has no daemon to ask, falls back to localhost exactly as
before, which is what a container build gets.
The fallback is development-only, deliberately. A production build inlines
these origins into the bundle and turbo keys its build cache on DEV_HOST;
deriving a host the cache key cannot see would let a cached bundle ship
somebody's tailnet address to production. Deploys name real origins through
<APP>_ORIGIN, so the fallback never runs there.
Using it
In the docs, <AppLink> and the app prop on <Feature> resolve at render —
so the same page is right in every environment it is read in.
<AppLink app="web" path="/prop-screener">Prop screener</AppLink>
<Feature icon="Chart" title="Prop screener" app="web" path="/prop-screener">
Open the running tool from its documentation.
</Feature>In a Next config or a script, call it directly. It is CommonJS, because the
next.config.js files are, and a second ESM copy for the TypeScript callers is
exactly the drift the registry exists to end.
const { appOrigin } = require("statshub-tooling/apps");
const API_ORIGIN = appOrigin("api");
const LEGACY_ORIGIN = appOrigin("legacy");To see what the current environment resolves to:
node packages/statshub-tooling/apps.cjsWhere state lives
On web the server fetches server state and passes it as props; the URL owns the rest, through nuqs. On mobile React Query owns server state and a zustand store owns the rest, because there is no URL to put it in.
Writing docs
The house style for this site — where a page lives, what shape it takes, which components are available, and what fails the build.