Files
school_compare/docs/superpowers/specs/2026-07-30-server-client-split-design.md
T
TudorandClaude Opus 5 7199c3a90b docs(perf): implementation plan for detail-page server/client split
Nine tasks mapping to the spec's commit sequence, each ending in an
independently testable deliverable.

Also corrects the spec's section-sharing table: measured similarity shows
Admissions (14%) and History (40%) are not shareable between the two views,
only Ofsted (80%) and Finances (91%). This costs no bytes -- the win comes
from sections being server components, not from sharing them -- and cuts the
diff and regression risk.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-01 20:52:56 +01:00

14 KiB

Detail pages: server/client split

Date: 2026-07-30 Status: Approved, ready for implementation planning Branch: perf/server-client-split

Problem

Every page of the site ships ~172 KB gzipped of JavaScript before any page-specific code loads. The 404 page costs that much; /admissions, a static content page with no data, costs 177 KB. A well-tuned Next.js app baseline is 90-110 KB.

The cause is that nearly the whole site is a client component. SchoolDetailView.tsx is 65 KB of source marked 'use client'; SecondarySchoolDetailView.tsx is 46 KB. Both are overwhelmingly presentational markup driven by props the server already fetched. Every stat tile, table row and Ofsted badge is serialised into the RSC payload and shipped again as JavaScript to hydrate.

Measured on a production build of cab7b4f (Next 16.1.6, Turbopack):

Metric Value
Baseline JS, every page 172 KB gz across 8 chunks
/admissions (no data) 177 KB gz
Chart.js chunk 64 KB gz (correctly lazy)
Leaflet chunk 41 KB gz (correctly lazy)
CSS ~30 KB gz, route-split

Scope of this document. The table above characterises the whole problem; this design addresses one part of it — the route-level client JS on school detail pages, the site's highest-traffic and most SEO-critical route. The shared baseline and the other routes are separate work, tracked elsewhere.

What makes this tractable

Two properties of the existing code make an aggressive split low-risk.

The section bodies are almost entirely static. Across ~750 lines of sections in SchoolDetailView (lines 596-1350), the only interactivity is five charts (already dynamic({ ssr: false }) islands), fourteen <MetricTooltip> instances (already a small client leaf), and two buttons driving the admissions year/trend toggle. Everything else renders props.

The scroll-spy is already decoupled. SchoolDetailView.tsx:283 locates sections with document.getElementById(id), and navItems (lines 258-267) is computed purely from data. The navigation does not care which component renders the sections, so server-rendered children work unchanged.

There is also a precedent to follow: components/compare/ is already decomposed this way — one file per section plus a sectionShared.tsx of primitives.

Architecture

A server component imported by a client component becomes a client component. So the sections cannot be children of a client SchoolDetailView. They are composed in page.tsx and passed through the client shell as children — the pattern already used at app/page.tsx:98-99.

The header and navigation sit above the sections, and the sections are plain siblings, so a single children slot suffices. Named slots were considered and rejected: more ceremony, no benefit when sections render in sequence.

app/school/[slug]/page.tsx                     server; fetches details + nationalAverages
│   computes isSecondary / isAllThrough / navItems
└── <SchoolDetailShell schoolInfo navItems>    'use client' — the only large client file
    │     back link · header + details reveal · hero map · compare CTA
    │     sticky section nav · scroll spy · jump sheet
    └── children: <PrimarySchoolSections/> | <SecondarySchoolSections/>   server
            └── components/school/             all server, mirroring components/compare/
                  OfstedSection · ResultsSection · AdmissionsSection
                  InclusionSection · HistorySection · SchoolLifeSection
                  LocalAreaSection · FinancesSection · WellbeingSection
                  sectionShared.tsx            Section/Card/Row primitives

Phase branching stays out of the route file: two thin server components (PrimarySchoolSections, SecondarySchoolSections) return the section sequence, and page.tsx picks one.

Client leaves inside server sections

Importing a client component into a server component is the legal direction, so these stay client and are used from server sections unchanged:

  • MetricTooltip / InfoPopover — exist, unchanged
  • PerformanceChart, SatsChart, AdmissionsTrendChart — exist, stay dynamic({ ssr: false })
  • AdmissionsViewTogglenew, ~25 lines; receives server-rendered year-view and trend-view as two props and swaps between them
  • DeltaChip, SpecialSchoolNote — already server-compatible (no 'use client')

What stays in the client shell, and why

The shell is genuinely interactive throughout: router.back(), the details reveal, IntersectionObserver hero-CTA tracking, the nav overflow fade, the Escape-to-close sheet, and the comparison context. The goal is not to shrink the shell but to stop the other ~750 lines being attached to it.

Section sharing

Sharing is decided by measured similarity, not assumption. Comparing the two views' section bodies line-by-line (difflib.SequenceMatcher on stripped lines):

Section Primary Secondary Similarity Decision
Finances 29 lines 35 91% Shared
Ofsted 88 107 80% Shared
History 121 47 40% Separate
Admissions 89 55 14% Separate

Admissions and History were assumed shareable when this design was first drafted; measurement during planning showed they are not — primary's admissions carries the year/trend toggle and chart, secondary's is a much simpler panel.

This costs nothing in bytes. The client-JS win comes from a section being a server component, not from being shared between views. Sharing is a maintainability benefit, taken only where it is cheap. Forcing the two low-similarity sections together would inflate the diff and the regression risk for no performance gain.

Shared Primary only Secondary only
Ofsted, Finances, sectionShared primitives, and the shell Results (KS2), Admissions, History, Inclusion, School Life, Local Area GCSEs, Admissions, History, Wellbeing

All-through schools render both the KS2 and KS4 section sets via the primary composer.

CSS strategy

Shared section components need one stylesheet, but the two existing CSS modules disagree. Measured on cab7b4f: SchoolDetailView.module.css defines 142 classes, SecondarySchoolDetailView.module.css 112, with 79 names in common — of which 22 have different rules, and 16 of those are used inside section markup by both views (.card, .sectionTitle, .metricCard, .metricValue, .metricsGrid, .tableWrapper, the heroStat* set, the genderSplit* set, .historyToggle, .ofstedReportLink).

The 16 conflicts split into two kinds:

Incidental drift (14 classes). One view carries defensive overflow properties the other lacks — min-width: 0, overflow-wrap: break-word, word-break: break-word, max-width: 100%, -webkit-overflow-scrolling: touch. These are independent bug-fixes that were never back-ported, not design decisions. Resolution: take the union. No visible change, and it fixes latent long-school-name overflow on whichever page currently lacks the property.

Real visual differences (2 classes).

Class Primary Secondary
.genderBar height: 6px, background: var(--border-color, #e5dfd5), width: 100% height: 4px, background: rgba(0, 0, 0, 0.08), margin-top: 0.45rem
.heroStatValue gap: 0.5rem, justify-content: flex-start, Playfair fallback includes Georgia gap: 0.4rem, Playfair fallback omits Georgia

Resolution: keep both looks as distinct classes and have the owning section take a variant: 'primary' | 'secondary' prop selecting between them. Zero intended visual change, one shared stylesheet, and the divergence becomes explicit rather than accidental.

The merged stylesheet is components/school/schoolSections.module.css. Shell-only rules stay behind in the per-view modules. Classes outside the 79 shared names move across unchanged.

Prerequisite: national averages move server-side

SchoolDetailView.tsx:173-179 fetches /api/national-averages in a useEffect, and primaryAvg / secondaryAvg feed the England-comparison deltas across most sections. Those sections cannot be server components until that data arrives as a prop. SecondarySchoolDetailView.tsx:91-93 has the same fetch.

page.tsx will fetch it in parallel with the school details (the endpoint is backend-cached for an hour) and pass nationalAvg down. This is a prerequisite, not an optional extra. It independently removes a client round-trip per detail page and puts the deltas in the initial HTML instead of popping in after hydration.

Layout

Footer is already a server component — no change needed. Navigation genuinely requires client (usePathname plus the comparison count) and stays as it is. This item is much smaller than the original review estimated.

Unused dependency

hooks/useSchools.ts, useFilters.ts, useMetrics.ts and useSchoolDetails.ts are imported by nothing. They are the only consumers of swr. All four files and the swr dependency are deleted.

Testing

SchoolDetailView will not exist as a single component afterwards, so characterization tests must not import it directly. A single render helper absorbs the change:

__tests__/support/renderSchoolDetail.tsx     the only file that changes at the split
   before:  render(<SchoolDetailView {...props} />)
   after:   render(<SchoolDetailShell {...}><PrimarySchoolSections {...} /></SchoolDetailShell>)

Every assertion imports that helper and never changes. Assertions passing untouched across the refactor is the proof that behaviour is preserved.

Coverage: Ofsted panel (rating, date, report link); KS2 results rows and England deltas; KS4 Attainment 8, Progress 8 and EBacc; admissions Q&A and the year/trend toggle; inclusion and wellbeing figures; the history table; finances; and the conditional navItems set.

Fixtures: four shapes — primary, secondary, all-through, and special school. The special-school path drops the England comparison via isSpecialSchool() and is the site of a known past regression (PR #70), so it gets explicit coverage.

Environment gaps to close in jest.setup.js: jsdom provides neither IntersectionObserver (used twice in each view) nor window.scrollTo. Chart.js needs a canvas, so the chart components are mocked. next/jest already handles CSS modules, and next/navigation is already globally mocked. Section components are synchronous functions, so React Testing Library renders both the server and client pieces in jsdom without special handling.

E2E: e2e/journeys.spec.ts gains detail-page assertions. CLAUDE.md requires e2e updates in the same PR as user-facing changes.

Commit sequence

One PR, but the diff is large, so it is structured to stay reviewable and bisectable:

  1. test: jest.setup additions (IntersectionObserver, scrollTo), chart mocks, fixtures
  2. test: characterization tests against the current components — all green before anything moves
  3. refactor: server-side nationalAverages in page.tsx; drop both useEffect fetches
  4. refactor: extract the shared derived-flags/navItems module
  5. style: merge the two CSS modules into components/school/schoolSections.module.css per the CSS strategy above
  6. refactor: extract components/school/sectionShared.tsx and the server sections (pure moves, no logic edits)
  7. refactor: add SchoolDetailShell and AdmissionsViewToggle; rewire page.tsx; delete the two old views
  8. chore: delete the four unused SWR hooks; drop swr from package.json
  9. test: extend e2e/journeys.spec.ts with detail-page assertions

Commits 6 and 7 move the bytes; commits 1 and 2 are the safety net that makes them safe. Commit 5 is isolated so a visual regression bisects to the CSS merge rather than to a JSX move. Commit 8 lands late so it cannot confuse a bisect.

Verification

Before the work is claimed complete:

  • npm run typecheck, npm test and npm run build all green
  • characterization tests passing unmodified from commit 2
  • before/after gzipped JS per route recorded in the PR description, measured the same way as the figures in the Problem section

Success criteria

  • /school/[slug] route-specific JS drops to roughly 40-60 KB gz, from a client tree currently built out of a 65 KB and a 46 KB source file
  • No visual or behavioural change on any of the four school shapes
  • One fewer client API round-trip per detail page

This change does not reduce the 172 KB baseline. That baseline is react-dom, the Next.js runtime, and the layout's client components (Navigation, ComparisonProvider, ComparisonToast) — none of which this design touches, as Navigation legitimately needs usePathname and the comparison count. The baseline is a separate piece of work; it is cited above only to establish why route-level client JS matters. Reporting must state the two numbers separately so the unchanged baseline is not read as a failure of this change.

Budget enforcement in CI was considered and deliberately deferred: measurement is recorded in the PR, but no new CI gate is added in this change.

Non-goals

  • No visual redesign or copy changes to any section
  • No change to Navigation, Footer, or any other route
  • No CI bundle-budget gate
  • Bugs found mid-move are recorded, not fixed inline

Risks

DOM drift. Moving ~750 lines of JSX risks subtle markup changes that CSS modules depend on. Commits 4 and 5 are strict moves and the section CSS moves with them; any styling change beyond the CSS-merge rules above is out of scope.

CSS merge. Merging two large stylesheets is the highest-risk part of the work. The union rule is mechanical, but a missed conflict shows up as a visual regression that no unit test catches. The merge gets its own commit, and the 16 known conflicts are enumerated above so they can be verified individually.

All-through schools. The trickiest case: isAllThrough gates KS2 content back on for a secondary-phase school (app/school/[slug]/page.tsx:150-154). It gets a dedicated fixture.

Large diff. Accepted deliberately in favour of designing the shared section library against both consumers at once. Mitigated by the commit sequence above.

swr removal is safe — nothing imports those hooks.