# 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 `` 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 └── 'use client' — the only large client file │ back link · header + details reveal · hero map · compare CTA │ sticky section nav · scroll spy · jump sheet └── children: | 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 })` - `AdmissionsViewToggle` — **new**, ~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() after: render() ``` 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.