StatsHub Docs
Audits

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

#RuleWhereSeverityStatus
1Reads from client components instead of Server Components160 useSWR sites, 52 files, statshub-webHighIn progress — 8 boards converted, 142 calls left
2Redundant runtime route segment configstatshub-webLowFixed
3proxy.ts in two different locations across appsstatshub-docs vs the other twoCosmeticLeft as-is
4No cacheHandler for multi-instance ISRstatshub-webConditionalLeft as-is
5<Image fill> with no sizescomponents/content/chat-preview.tsxLowFixed
6No priority on the LCP imagecomponents/content/entity-detail.tsxMediumFixed

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-club and prop-screener are gated on a signed-in user and are written by a toggle. They belong in a server action with revalidateTag, 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:

app/(dashboard)/(features)/(stats)/referees/page.tsx
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:

  1. A filter mapping that both sides can reach. A board with more than a handful of filters had its URL→API translation inside a useMemo over the useQueryState values. The server needs the same mapping, so it moves to a query.ts next to the view — and that file must not import nuqs (see below).
  2. 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.
  3. isLoading becomes the transition's pending flag. Pass startTransition from useTransition into the nuqs options; pending then covers exactly the window between writing a filter and its rows arriving.
  4. 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.
  5. The error branch goes away. A failed read throws in the server component and lands on the segment's error.tsx, so hasError/onRetry become router.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.ts

runtime-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.

On this page