Files
school_compare/docs/superpowers/specs/2026-07-30-server-client-split-design.md
T
TudorandClaude Opus 5 d1c92ea7d5 docs(perf): spec for detail-page server/client split
Design for splitting SchoolDetailView (65KB) and SecondarySchoolDetailView
(46KB) into server section components behind a small client shell, following
the existing components/compare/ decomposition pattern.

Key constraint: a server component imported by a client component becomes
client, so sections are composed in page.tsx and passed through the shell as
children. Moving /api/national-averages server-side is a prerequisite, since
the England-comparison deltas feed most sections.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-30 15:35:54 +01:00

11 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

Shared by both views Primary only Secondary only
Ofsted, Admissions, History, Finances, and the shell Results (KS2), Inclusion, School Life, Local Area Wellbeing

All-through schools render both the KS2 and KS4 section sets. The shared library makes this natural rather than duplicated.

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 components/school/sectionShared.tsx and the server sections (pure moves, no logic edits)
  5. refactor: add SchoolDetailShell and AdmissionsViewToggle; rewire page.tsx; delete the two old views
  6. chore: delete the four unused SWR hooks; drop swr from package.json
  7. test: extend e2e/journeys.spec.ts with detail-page assertions

Commits 4 and 5 move the bytes; commits 1 and 2 are the safety net that makes them safe. Commit 6 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 is out of scope.

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.