ADR-0060: Server-render data-backed list/detail pages (server-rendered first paint + DRY loaders)
Status: Accepted (2026-06-18) · Amended 2026-06-18 (see ERRATA — the original "fixes the nav flicker" justification was disproven; the pattern is retained for its real benefits)
Supersedes / relates to: ADR-0017 (layout architecture — route groups), the app-wide session-cache warm-nav layer (PR #2453), the global nav progress bar that actually fixes the held-page perception (nextjs-toploader in the root layout — see docs/planning/nav-flicker-fix-handoff.md)
⚠️ ERRATA (2026-06-18) — read first
This ADR originally claimed the pattern fixes the held-old-page nav flicker
by making loading.tsx fire on navigation. That claim is false and was
disproven (local production build, prefetch on, loading.tsx marker test):
clicking a side-nav item to a sibling route holds the previous page ~300ms
and loading.tsx never paints — regardless of nav mechanism (SideNav <Link>
and a vanilla next/link both behave identically). This is inherent App
Router / React transition behavior: a client navigation is a React transition,
and React deliberately will not show a Suspense fallback that would hide
already-visible content — so for a sibling-slot swap the old page is held until
the new one resolves. loading.tsx reliably shows on initial / hard loads,
not sibling soft-nav.
The held-page perception is fixed separately by a global top navigation
progress bar (nextjs-toploader, root layout) that gives immediate click
feedback during the inherent hold — see docs/planning/nav-flicker-fix-handoff.md.
This pattern is retained because its other benefits are real and proven (below). The decision/structure stand; only the "fixes the flicker / loading.tsx fires on nav" rationale is withdrawn.
Context
Data-backed list/detail pages were 'use client' and fetched their primary data
in useEffect / usePolling. Two issues with that shape (independent of the
nav-flicker question, which the progress bar now owns):
- Cold first paint shows nothing real. The client component mounts empty,
renders a ghost, then its data round-trip returns and the real content pops
in — a visible ghost-then-data flash on the cold first navigation into a page
(the
session-cachelayer only makes warm revisits instant). - Two code paths for the same read. The page's client fetch and the matching API route's GET each re-implemented the same auth/property/org scope + shaping, free to drift apart.
Decision
Data-backed list and detail pages are server components that await
their data, with the data baked into the first paint (no client
ghost-then-data flash on cold load), read through a single loader shared with
the matching API route. (The route segment does suspend and loading.tsx
does fire on hard/initial loads — but NOT on sibling soft-nav; see ERRATA.
The first-paint + DRY benefits below do not depend on the nav-transition claim.)
The canonical structure (three parts):
A
server-onlyloader —src/lib/domain/<area>/load-<thing>.ts, holding the fetch + auth/property/org scope + shaping. It is shared with the matching API route's GET (the route refactors to call it) so the route and the page produce byte-identical results from one code path. For detail routes the loader returns a discriminated result ({ ok: true; data } | { ok: false; status; error }) so the route keeps its exact 404s and the server page can fall back.An
asyncserverpage.tsx—awaits the loader (this is the suspense point) and renders<NameClient initialData={…} />. On not-found it passesundefinedso the island keeps its existing fetch/error path.A client island —
<Name>Client.tsx(the old page body,git mv'd) that takes an optionalinitial<Data>prop and seeds its first render from it (initialData ?? readSessionCache(...) ?? empty); the existing poll + session-cache keep revalidating. Modals / filters / secondary fetches stay in the island — only the primary, gating read moves server-side.
Verification standard for each conversion: tsc clean → response parity diff
(capture the live API response before the change, diff after — must be
byte-identical) → the route's existing tests → page renders 200. (The original
"Playwright check that loading.tsx fires" is dropped — per the ERRATA it does
not fire on sibling soft-nav, so it was never a valid acceptance check; parity +
render are.)
When it does NOT apply (legitimate exemptions)
- Forms / wizards (POST-driven, no gating display read) — the loading.tsx is harmless but the pattern adds nothing.
- Interactive tools whose primary UX is action, not data display (chat harnesses, probes, iframe previews, permission-toggle matrices).
- Multi-card / multi-tab dashboards with NO single gating fetch — each card
fetches independently, so there's nothing to
awaitat the segment (e.g. the agent-smith dashboard). Converting would award no suspense benefit.
These are recorded in the drift guard's ALLOWLIST with a reason.
Enforcement
- Drift guard (hard CI gate):
src/__tests__/page-suspense-pattern.drift.test.tsfails when a(operations)page.tsxis a Client Component and has a siblingloading.tsxand fetches/api/…— i.e. a data-backed page that hasn't moved its primary read server-side (no server-rendered first paint, and theloading.tsxsitting next to it never fires for that page). New pages must follow the pattern or join the documented ALLOWLIST. - Reviewer lens:
.github/reviewer-lenses/next-react-ui.mdcarries the rule so the cloud reviewer flags it on the diff.
Consequences
- New list/detail pages get a server-rendered first paint (real data in the initial HTML, no cold ghost-then-data flash) if authored to the pattern; the guard makes the wrong shape fail before merge. (It does not fix the soft-nav held-page — the nav progress bar owns that; see ERRATA.)
- The loader-shared-with-route rule keeps the page and its API on one code path (no scope-logic duplication — the data-layer dedup lens still applies). This is the strongest durable benefit and is parity-verifiable.
- The pre-existing ALLOWLIST captures the debt (shared-route list pages + tools) to shrink over time; it must not grow without justification.
- Worked examples:
docs/planning/suspense-render-rollout-handoff.md(21 pages converted across maintenance / leasing / tenants / vendors / properties / admin-dev, each parity-verified).