Architecture

How a reading is assembled, why this is a server-rendered app, and how it degrades.

Next.js 15 App Router · React 18 · TypeScript strict · Tailwind 3.4 · deployed on Netlify.

Why there is a server at all

The reference price API sends no CORS headers, so a browser cannot fetch it. That single fact decides the shape of the whole application: the number everything is measured against has to be read server-side.

Server components fetch both upstreams during render, so the first paint already contains real data — no loading spinner, no client waterfall, no exposed API surface for the reference. See docs/data-sources.md §1.

Data flow

  RHJ prices ─┐
  RHJ assets ─┼─► lib/sources/rhj.ts ────┐
  RHJ actions ┘   (server-only)          │
                                          ├─► lib/readings.ts ──► getReadings()
  DEX Screener ─► lib/sources/dexscreener.ts                         │
                                                    ┌───────────────┴──────────────┐
                                                    ▼                              ▼
                                            app/page.tsx                   app/api/v1/*
                                        (server component)                (route handlers)

getReadings() is the single assembler. Both the page and every API route call it, so they can never disagree about a number — which would be the fastest possible way to lose the credibility the product is built on. Components never fetch upstream.

Revalidation is 60 s (BASIS_REVALIDATE_SECONDS). The reference endpoint is itself cached 15 s upstream, so anything below that wastes calls.

Server and client boundaries

Almost everything is a server component. 'use client' appears only where browser state is genuinely required:

IslandWhy
site/SessionClockTicks the clock, writes data-session on <html>
hero/HeroToken switching, live spread, the pool field with its priced axis
data/PoolFieldCanvas
data/ReadoutTableSorting and row expansion
data/ExecutionCheckSize and token selection
data/VerifyDrawerOpen state, clipboard
site/NodeDiagramHover highlighting
site/SessionStripLive now-marker
ui/SectionHeaderIntersectionObserver reveal

npm run build is the real guard here — a server-only import leaking into a client component fails the build rather than surfacing at runtime.

Degradation

Each source is timed independently and can fail on its own. When one does, the affected rows come from lib/fallback.ts (the verified 2026-09-02 capture), are marked stale, and flag DARK. /api/v1/status reports which source failed and how many rows are affected.

The UI then says so — a banner on the readout, and [ CAPTURE ] rather than [ LIVE ] in the instrument bar. Nothing is silently presented as current.

The session engine

lib/session.ts is pure and has no I/O, so it produces identical results on the server and in the browser. That matters: the server renders data-session into the markup for a correct first paint, and SessionClock then keeps it current. If the two disagreed, the page would flash the wrong reference colour on hydration.

Eastern time comes from Intl.DateTimeFormat with a timeZone, not a fixed UTC offset, so daylight saving is the platform's problem rather than ours.

Persistence, and the scheduled tick

src/lib/store.ts is the first thing in the project that remembers anything between requests. On the host it is Netlify Blobs — a key-value store that exists on every site with no provisioning; locally it is a Map, and the API says which (storage: "memory") so a local run is never mistaken for a record. It holds the treasury ledger, keyed by minute, and is written idempotently so a minute recorded twice is one entry.

netlify/functions/ledger-tick.mts runs once a minute and calls /api/v1/treasury, which does the recording. The function does nothing itself — one code path, the site's own assembler, so the scheduled record and a visitor's read can never disagree.

src/lib/sources/treasury.ts reads the treasury wallet over the public RPC: eth_getBalance and an ERC-20 balanceOf, batched in one call, block-stamped. It holds no key. It is server-only for the same reason rhj.ts is.

Design system as code

app/globals.css holds the tokens as CSS custom properties, the eight motion keyframes, and the :root[data-session] overrides that re-tune the reference colour as the market state changes. Because it is a custom property, changing the session restyles every figure on the page at once with no prop drilling and no re-render of the data.

/system renders the actual components, never copies, so the design reference cannot drift from what ships.

Testing

src/lib/logic.test.mjs runs under node --test with no framework and no build step. It covers the two things that would be genuinely damaging to get wrong: the session boundaries that decide whether a reading is trustworthy at all, and the multiplier that decides whether a number is real. Plus the execution maths, pinned to an independently verified figure (NVDA $25k → $1,427).

Deployment

Netlify. netlify.toml pins Node 22, declares the Next.js runtime explicitly rather than leaving it to auto-detection, and sets the security headers. Server components and route handlers run as functions; revalidate maps onto the platform's ISR, and the handlers under /api/v1 become serverless functions.

No environment variables are required — both upstreams are keyless, so a deploy with none set still renders live numbers. The interpreter's three variables (INTERPRETER_API_KEY, INTERPRETER_ENDPOINT, INTERPRETER_MODEL) are the only ones worth setting, and only to turn on the explanations at /desk; unset, that panel says so and nothing else changes.

CI builds with BASIS_OFFLINE=1 so the build never depends on a third-party API being up. That also exercises the degradation path: every row falls back to the stored capture and renders DARK.

Any Node host works. Add output: 'standalone' to next.config.mjs and run node .next/standalone/server.js. A static export is not viable: it would lose the live reference and the API routes, leaving a snapshot.