Next.js convention audit
Every App Router rule the three current Next apps break as of 2026-08-27, what was verified as already correct, and which findings were fixed on the spot.
Date: 2026-08-27
Apps audited: statshub-web (16.3.2), statshub-admin (16.3.0), statshub-docs (16.3.0), plus legacy (14, Pages Router) where it is still serving traffic
Rules applied: the next-best-practices skill — file conventions, RSC boundaries, async patterns, runtime selection, data patterns, error handling, suspense boundaries, images, self-hosting
The short version: these apps are in better shape than an audit usually finds.
There is no raw <img>, no Edge runtime chosen by accident, no async client
component, no synchronous params, and every useSearchParams consumer sits
inside a Suspense boundary. One rule is broken at scale, and it is the one that
costs the most.
Findings
| # | Rule | Where | Severity | Status |
|---|---|---|---|---|
| 1 | Reads from client components instead of Server Components | 160 useSWR sites, 52 files, statshub-web | High | In progress — 8 boards converted, 142 calls left |
| 2 | Redundant runtime route segment config | statshub-web | Low | Fixed |
| 3 | proxy.ts in two different locations across apps | statshub-docs vs the other two | Cosmetic | Left as-is |
| 4 | No cacheHandler for multi-instance ISR | statshub-web | Conditional | Left as-is |
| 5 | <Image fill> with no sizes | components/content/chat-preview.tsx | Low | Fixed |
| 6 | No priority on the LCP image | components/content/entity-detail.tsx | Medium | Fixed |
Reads that go out to the browser and back
This is the one that matters. data-patterns puts it plainly: a read from a
Server Component is fetched directly, and a read from a client component needs a
route handler and a round trip. statshub-web has 160 useSWR call sites
across 52 files, and every one of them is a read.
The cost is not theoretical. A board like /referees made two requests in
series — the list, then the card odds keyed on the fixture ids the list came
back with — both from the visitor's browser, both crossing the public internet
to reach an API that is one hop away from the Next server. The first paint was a
skeleton in every case, including for a crawler.
The counting is worth a note, because it is easy to get wrong: grep useSWR(
finds 38 sites and grep useQuery finds none, but the dominant style in this
codebase is useSWR<Response>(…), which neither pattern matches. useSWR[<(]
is the one that finds all 160. There is no React Query in this app at all,
despite the monorepo rule that names it — SWR is what is actually installed.
The split
Not all 160 should move. They divide cleanly:
- Page-level views — convertible. The board is the page, its filters are in
the URL, so the server has everything it needs to build the same request.
composed/*:bet-builder(14),value-bets-v3(6),player-cards(6),ags-collection(6),prop-screener(5),hundred-club(5),dashboard-home(5),value-bets-v2(4),team-trends(3),team-screener(3),player-trends(3),outliers(3),value-bets(2),favourites(2),datavisualiser(2),super-sub-heroes(1),prophunter(1). - Interaction-triggered — should stay on the client. The 84 sites under
components/are overlays, sheets, charts and grids that open when a row is clicked. Fetching them at page render means fetching for every row on the board whether or not anyone opens one. If these must leave the client, the mechanism is a server action per interaction, not page-level fetching. - Per-user and mutable — neither. The watchlist reads in
hundred-clubandprop-screenerare gated on a signed-in user and are written by a toggle. They belong in a server action withrevalidateTag, not in a page fetch.
What the conversion looks like
Eight boards are done — /, /referees, /lineups, /100-club,
/super-sub-heroes, /favourites, /value-bets, /outliers — and they set
the pattern:
export default async function Page({ searchParams }) {
const params = await searchParams;
const board = await getPublicApi<{ data: RefereeData[] }>(
"/api/referees/list",
{ query: { sortField: params.sort ?? "avg_cards", /* … */ } }
);
const fixtureIds = (board?.data ?? [])
.map((referee) => referee.next_game_id)
.filter((id): id is number => id !== null);
const cardOdds = await postPublicApi<CardOddsByEvent>(
"/api/odds/batch-card-odds",
{ eventIds: fixtureIds }
);
return <View referees={board?.data ?? []} cardOdds={cardOdds ?? undefined} />;
}Four things have to move with the data, and skipping any of them breaks the board rather than merely slowing it:
- A filter mapping that both sides can reach. A board with more than a
handful of filters had its URL→API translation inside a
useMemoover theuseQueryStatevalues. The server needs the same mapping, so it moves to aquery.tsnext to the view — and that file must not import nuqs (see below). - Filters that change the response become
shallow: false. nuqs writes the URL; without this the server component never re-runs and the board freezes on its first render. Filters the browser applies to rows it already has — a page number, column visibility, a client-side date filter — stay shallow and stay instant. isLoadingbecomes the transition's pending flag. PassstartTransitionfromuseTransitioninto the nuqs options; pending then covers exactly the window between writing a filter and its rows arriving.- A debounced search debounces the write, not a derived request URL.
limitUrlUpdates: { method: "debounce", timeMs: 500 }. Debouncing the old way now puts every keystroke in the address bar and in history. - The error branch goes away. A failed read throws in the server component
and lands on the segment's
error.tsx, sohasError/onRetrybecomerouter.refresh()and nothing else.
The waterfalls come with it
hundred-club chains tournaments → fixture ids → props → watchlist. Moving it
to the server makes those hops fast, not parallel. data-patterns asks for
Promise.all on independent reads; only the genuinely dependent ones should
stay in series, and each conversion is the moment to tell which is which.
Four things the conversion needs that the pattern above doesn't show
nuqs is client-only. A state.ts that builds parsers cannot be imported by
a Server Component: the build fails with "Attempted to call parseAsArrayOf()
from the server". Constants both the page and the parsers need go in a
defaults.ts that neither pulls the other into. This is the first thing that
breaks on almost every view.
Per-user reads need getPrivateApi. It forwards the request's cookies —
the Go API's auth.AccessToken takes the @supabase/ssr session cookie — and
is no-store, never a short revalidate. A cached response there is one reader's
watchlist served to the next one. getServerUser in lib/supabase/server.ts is
the matching session read for when the page needs the user's id.
Live data stays live without a client data layer. The home board polls
scores and lineup confirmations every 60s. The server seeds them; the view runs
router.refresh() on an interval. The server re-renders and streams new HTML,
and the client holds a timer rather than a second copy of the fetching logic.
"Today" is not the server's day. The fixtures board is a day at a time, and
a 21:00 match in Auckland is a different date in UTC. In the browser this was
free — new Date() in an effect. On the server the container is UTC and would
serve New Zealand readers yesterday's fixtures every evening. lib/server-time.ts
reads an IANA zone from a statshub_tz cookie written by TimeZoneSync, in the
same shape as statshub_locale, and falls back to UTC until it lands.
Redundant runtime config — fixed
export const runtime = 'nodejs'; // statshub-web src/app/api/agent/route.tsruntime-selection is explicit that Node is the default and needs no config.
The line is now gone. It was not merely noise: a route segment runtime
export is rejected outright under cacheComponents, so it was a
build error waiting for the day that flag gets turned on.
3. proxy.ts lives in two places
statshub-web and statshub-admin keep it at src/proxy.ts; statshub-docs
keeps it at the app root. Next resolves both — PROXY_LOCATION_REGEXP is
(?:src/)?proxy — so this is consistency, not correctness, and moving a working
proxy file to tidy a table is not worth the risk of finding out otherwise.
No ISR cache handler
output: "standalone" is set and the deploy is Coolify. self-hosting calls
for a shared cacheHandler when more than one instance serves the same ISR
routes, otherwise each container revalidates into its own filesystem and users
see different vintages of the same page. At one container per app this is
correct as it stands. It becomes a bug the day the web app is scaled out, and it
is worth writing down before that day rather than after.
5 and 6. Two image rules — fixed
Of 154 <Image> tags, exactly one used fill without sizes
(chat-preview.tsx). fill with no sizes makes the browser assume 100vw
and fetch the largest variant the optimizer will produce — for a 28px avatar.
It now declares (max-width: 640px) 28px, 32px.
More consequential: not one image in the app set priority. The rule is
that the above-the-fold LCP image should, because everything else is lazy by
default and the LCP image being lazy is a direct Core Web Vitals hit. The crest
in EntityDetail's header is the LCP element on /team, /player and
/referee — first thing on the page, only image in the header — so it sets it
now. That needed a priority pass-through on AvatarWithFallback in
packages/ui-web, which previously accepted no such prop; ImageWithFallback
below it already spread the rest onto next/image.
Do not spray this across lists
priority on avatars inside a table makes every row compete with the element
that actually decides the score. One per page, in the header.
Verified — do not "fix" these
Four things look like violations and are not. Each was checked against the installed Next, not against the rule.
proxy.ts exports config, not proxyConfig. The v16 rename table says
proxyConfig, but Next 16.3.2's dist contains zero occurrences of that
identifier; the matcher is read from config by getMiddlewareMatchers.
Renaming it to match the table would silently drop the matcher and run the proxy
on every request. The guidance is ahead of the installed version.
experimental.turbopackFileSystemCacheForDev is real. The build prints it
under "Experiments (use with caution)" with a ⨯ glyph that reads like an
error. It is a valid 16.3.2 key — 45 occurrences in dist — and the comment
above it names the bug it prevents.
useSearchParams is inside Suspense. NavigationLoader is the only
consumer mounted globally, and providers.tsx wraps it. No CSR bailout.
Nothing swallows notFound() or redirect(). No try block in src/app
wraps a navigation call, so no unstable_rethrow is needed anywhere yet. Worth
re-checking as the server conversions above add try/catch around fetches
that sit next to a notFound().
Also true, and not a Next rule
The four entity routes export generateStaticParams and the four [view] /
[scope] routes have always had it, yet a production build marks every one of
them ƒ (Dynamic). The cause is cookies(): (dashboard)/layout.tsx resolves
statshub_locale on the server, which opts the whole segment into per-request
rendering. Until the locale leaves the render path — by moving to the client, by
moving into the URL, or by turning on cacheComponents so it becomes a dynamic
hole in a prerendered shell — no amount of server-side data fetching will make
these pages static. cacheComponents additionally requires every
generateStaticParams to return at least one result, which means the Go API
would have to be reachable during next build, including inside docker build.
StatsHub SEO Audit Report
A full technical SEO review of the web app as it stood on 2026-01-29 — per-page grades, missing schema, and the fixes proposed at the time.
AI Tool Audit — Before & After Summarization
Every AI tool the assistant exposes, before and after the rework — what each one returned then, what it returns now, and why.