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
| Directory | What is in it |
|---|---|
src/app | App Router routes — all of them; there is no Pages Router left |
src/composed | One directory per board: value-bets, prophunter, lineups … |
src/components | App-local components, grouped by role — filters, layout, content, templates, reusable |
src/lib, src/server | Server-only helpers, including server-api.ts |
src/lib-client | Client state — contexts, hooks, and the /experimental-ui-prefixed link/navigation/activity adapters |
src/lib/ai | The rewrite's chat agent, its tools and its evals |
src/lib/legacy, src/lib-client/legacy | The 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:
| Group | Chrome |
|---|---|
(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:
| Target | What lands in it |
|---|---|
FULL_TOOL_FILTERS_ID | The filter panel's contents |
IMPORTANT_TOOL_FILTERS_ID | The two or three filters on the bar |
TOOL_SEGMENTED_ROWS_ID | Segmented controls under the primary row |
TOOL_FILTER_ROWS_ID | Search, actions, chip rails |
TOOL_SEARCH_ID | The tool's own text search, inline on the bar |
TOOL_BAR_ACTIONS_ID | Column visibility and the like, beside Clear |
RIGHT_SIDEBAR_ID | The 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.