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):

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):

  1. A server-only loadersrc/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.

  2. An async server page.tsxawaits the loader (this is the suspense point) and renders <NameClient initialData={…} />. On not-found it passes undefined so the island keeps its existing fetch/error path.

  3. A client island<Name>Client.tsx (the old page body, git mv'd) that takes an optional initial<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)

These are recorded in the drift guard's ALLOWLIST with a reason.

Enforcement

Consequences