StatsHub Docs

Architecture

How a page in the product site is put together — the route groups, the shell it mounts into, the portal targets a board fills, and the hand-over from legacy.

One layout, one shell, and a set of named holes that a board fills from wherever it happens to render. Almost everything below exists because the alternative was threading state through a route that does not own it.

Where the code lives

DirectoryWhat is in it
src/appApp Router routes — all of them; there is no Pages Router left
src/composedOne directory per board: value-bets, prophunter, lineups
src/componentsApp-local components, grouped by role — filters, layout, content, templates, reusable
src/lib, src/serverServer-only helpers, including server-api.ts
src/lib-clientClient state — contexts, hooks, and the /experimental-ui-prefixed link/navigation/activity adapters
src/lib/aiThe rewrite's chat agent, its tools and its evals
src/lib/legacy, src/lib-client/legacyThe ported legacy site's own lib/db/server/types/ai (server-safe) and contexts/hooks/page-adapter/providers/router (client) — namespaced separately because several files share a name with their rewrite counterpart but diverge in content

Shared components live in @statshub/ui-web, not here. The split is ownership: if a second app would want it, it belongs in the package.

There is no src/pages/api

It was deleted — the API is the API. The single exception is /api/agent, which streams and therefore has to be a Next.js route handler. Anything else that looks like it needs one almost certainly belongs in the Go service.

Route groups

src/app has four top-level groups, and they exist to give three different chromes without three different URL prefixes:

GroupChrome
(dashboard)The app shell — nav rail, filter bar, filter panel, right rail
(seo)A public layout, indexable
(marketing)Product pages for the things sold separately

Inside (dashboard), the nested groups — (betting), (props), (matchday), (stats), (tools), (entities) — add no layout at all. They are there so the file tree reads like the nav does.

The shell has slots

app/(dashboard)/layout.tsx takes three parallel routes beside children:

<AppShell
  filters={filters}                      // @filters
  importantFilters={importantFilters}    // @importantFilters
  rightSidebar={rightSidebar}            // @rightSidebar
>
  {children}
</AppShell>

No slot may hold a catch-all

A [...segments] in a slot matches every URL, which makes Next treat every path as routed. Two things broke while they were there: the LEGACY_ROUTES rewrites stopped firing, because a rewrite runs only when the app has no route for a path — so every detail page still owned by legacy became a dead end. And an unknown URL rendered the dashboard shell wrapped around not-found, answering 200 instead of 404.

default.tsx already covers the case a catch-all was reaching for: it is what a slot renders on a route it has no page for. All three return null.

A board fills the chrome, the chrome places it

The slots decide where something sits. What goes in it is rendered by the board, and travels there through a portal. src/components/reusable/tool-filter-portal.tsx holds every target:

TargetWhat lands in it
FULL_TOOL_FILTERS_IDThe filter panel's contents
IMPORTANT_TOOL_FILTERS_IDThe two or three filters on the bar
TOOL_SEGMENTED_ROWS_IDSegmented controls under the primary row
TOOL_FILTER_ROWS_IDSearch, actions, chip rails
TOOL_SEARCH_IDThe tool's own text search, inline on the bar
TOOL_BAR_ACTIONS_IDColumn visibility and the like, beside Clear
RIGHT_SIDEBAR_IDThe right rail's contents

The reason is always the same: the content depends on state only the board has — a selected row, a watchlist, the query the rows came from. A route rendering it would have to reach into state it does not own, or fetch it a second time and keep two copies in step.

A portal with no target renders nothing. That is the designed answer, not a failure: it is how a board that has no rail, or is below the rail's breakpoint, degrades without a conditional.

FeatureTemplate is the page shape

All 27 boards render the same three things — a strip of quick filters, a panel of the rest, and a body — and src/components/templates/feature-template.tsx is that shape in one place:

<FeatureTemplate
  scope={[{ label: "Player", href: "/player-trends/player" }, …]}
  filters={<FilterBarItem …>}
  advancedFilters={<FilterGroup>…</FilterGroup>}
  content={<ResultsTable />}
  faq={<Faq items={faqItems} />}
/>

Named slots rather than children, because a screener is as much its filters as its table. Assembling it by hand is what produced filter portals opened at whatever depth the JSX happened to reach — player-trends nesting the important filters two levels inside the full ones, outliers opening four separate portal blocks in one render.

There is no title or description slot, on purpose. A heading restating the page's own name pushed the board below the fold, and the breadcrumb and nav already say where you are.

The right rail

Chrome from the route, content from the page — the same split as the filter bar. PageRightSidebar holds the position, the width and the border and nothing else; the page portals its contents in and owns everything inside, down to the rail's own header.

Which routes have one is the slot's decision: a route gets a rail by mounting it at app/(dashboard)/@rightSidebar/<route>/page.tsx. Everything else resolves to the slot's default.tsx, which returns null. Today that is Home and the 100 Club.

It appears at lg — 1024px. md was tried: the nav rail and an open filter panel are 240 each, so at 800 the workspace was left nothing and the rail ran off the display with the hit rates cut in half. Below the breakpoint the target is still in the DOM (display: none, not absent), so a page with nowhere else to put its content would portal into the dark; useHasRightSidebar is the guard for pages that have an alternative.

proxy.ts, not middleware.ts

Next 16's rename. src/proxy.ts refreshes the Supabase session on every request that is not a static asset, and its matcher is where that exclusion list lives.

The hand-over from legacy

LEGACY_ROUTES in next.config.js lists what still belongs to legacy. Removing an entry is the hand-over. Those rewrites are matched before this app's own routes, so a page added here while its entry is still listed is unreachable.

fallback would have been the better bucket — it runs only when nothing else matched, so each page would retire its own entry — but fallback rewrites do not apply to App Router routes. The App Router renders not-found first, which is exactly what it did when that was tried.

On this page