# 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 })`
- `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(<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.
