# Phase 9 — SEO and performance engineering **Date:** 24 August 2026 **Baseline:** `73d1421` (Phase 8) --- ## 1. Executive summary Phase 9 measured the site before changing it, and the measurement found something the previous six phases had not: **the production site did not hydrate at all.** The Content Security Policy set in Phase 3 blocked every one of the five inline scripts Next.js uses to deliver the React Server Component payload, so React never started. In practice the mobile navigation drawer — the only navigation below the `lg` breakpoint, on a site built mobile-first for parents on Android — could not open. Lighthouse reported it in the first run of the phase. Four more real defects followed from measuring rather than assuming: `/stories` silently discarded every story past the sixtieth while serving 224 KB of HTML; an active filter chip rendered at 1.97:1 contrast in dark mode; the site had no favicon and 404'd on every page load; and the year filter chips ignored the programme filter, which Phase 8 had already flagged. All five are fixed. Alongside them: fonts on the critical path fell 25%, the sitemap now derives its dates from real content instead of the build clock, filtered URLs are kept out of the index without being blocked from crawling, and the byte budgets are now enforced by a script rather than described in a document. **Automated checks: 276 → 697.** Lighthouse desktop: Performance 100, Accessibility 100, Best Practices 100, zero console errors. The database ends the phase with **0 rows in every table**, and no institute fact was invented. --- ## 2. Baseline measurements Taken from a production build (`next start`), before any Phase 9 change. **All byte figures are compressed wire bytes counted off the socket.** > An earlier draft of the measurement script used `fetch`, which transparently > decompresses, and reported 612 KB of JavaScript for a page that transfers > 189.6 KB. Every number below is what the socket actually carried. ### 2A. Empty database — the current real state | Route | TTFB | HTML | JS | CSS | Font | Total | Requests | | --- | ---: | ---: | ---: | ---: | ---: | ---: | ---: | | `/` | 9 ms | 7.4 KB | 189.6 KB | 8.9 KB | 118.6 KB | 324.4 KB | 17 | | `/about` | 11 ms | 7.6 KB | 189.6 KB | 8.9 KB | 118.6 KB | 324.6 KB | 17 | | `/courses` | 7 ms | 7.2 KB | 189.6 KB | 8.9 KB | 118.6 KB | 324.2 KB | 17 | | `/courses/[slug]` | 8 ms | 7.2 KB | 189.6 KB | 8.9 KB | 118.6 KB | 324.2 KB | 17 | | `/results` | 33 ms | 8.9 KB | 189.6 KB | 8.9 KB | 118.6 KB | 325.9 KB | 17 | | `/stories` | 12 ms | 7.0 KB | 189.6 KB | 8.9 KB | 118.6 KB | 324.0 KB | 17 | | `/announcements` | 9 ms | 6.7 KB | 189.6 KB | 8.9 KB | 118.6 KB | 323.7 KB | 17 | | `/contact` | 9 ms | 7.1 KB | 189.6 KB | 8.9 KB | 118.6 KB | 324.1 KB | 17 | | `/admissions` | 27 ms | 10.1 KB | 192.1 KB | 8.9 KB | 118.6 KB | 329.6 KB | 18 | ### 2B. 1,000 synthetic students — realistic scale Fixture: 1,000 published+consented results, 3,000 subject scores, 80 stories, 30 batches, 12 announcements, 500 enquiries. Every row prefixed `ZZTEST`, all deleted afterwards (§23). | Route | TTFB | HTML (wire) | **HTML (raw)** | Total | Requests | | --- | ---: | ---: | ---: | ---: | ---: | | `/` | 8 ms | 10.3 KB | 79.0 KB | 327.3 KB | 17 | | `/results` | 19 ms | 11.9 KB | 118.3 KB | 328.9 KB | 17 | | `/stories` | 9 ms | 12.8 KB | **223.7 KB** | 329.9 KB | 17 | That 223.7 KB is the finding of the table. Compressed it looks harmless at 12.8 KB, because repeated markup compresses beautifully — but the browser still parses all 223.7 KB. It is also the number that exposed the truncation bug: the page was rendering 60 of 80 published stories and saying nothing about the other twenty. ### 2C. Font composition | Family | Latin file(s) | Bytes | Preloaded | | --- | ---: | ---: | :-: | | Source Serif 4 | 1 (variable, 400/600/700) | 49.7 KB | yes | | IBM Plex Sans | 1 (variable, 400/500/600) | 39.3 KB | yes | | IBM Plex Mono | **3 (static, one per weight)** | **29.5 KB** | yes | 118.6 KB of font, all five files on the critical path, competing with the CSS at the exact moment the LCP text needed to paint. ### 2D. Public queries at 1,000 published results `EXPLAIN (ANALYZE, BUFFERS)` against real PostgreSQL 18.4: | Query | Plan | Execution | | --- | --- | ---: | | results page 1 | index scan + top-N heapsort | 0.27 ms | | results filtered (programme + year) | bitmap index scan on `toppers_programme_year_idx` | 0.08 ms | | year facet grouped | bitmap index scan on `toppers_programme_year_idx` | **0.163 ms** | **No index was added, and no migration was written.** The existing indexes are used, and at this scale the queries are effectively free. Adding an index to a query that executes in a sixth of a millisecond would have been work performed to look thorough. ### 2E. Build output — rendering mode per route `/` `/about` `/courses` `/announcements` `/contact` static · `/courses/[slug]` SSG ×5 · `/results` dynamic · `/admissions` dynamic · `/admin/*` dynamic. --- ## 3. Performance budgets The existing budget was inspected before anything new was written. **Master Plan §18 set 120 KB gzip of homepage JavaScript.** Phase 3 measured 188.8 KB, and proved the cause was not application code by measuring a route with *no client components at all* and getting byte-for-byte the same bundle. Phase 3 recommended ~200 KB as a regression tripwire and left the decision open. **Phase 9 re-measured five phases later: 189.6 KB.** 0.8 KB of growth across four phases of feature work. That confirms both the diagnosis and the number, so **200 KB is now encoded** rather than merely recommended. `scripts/verify-budget.mjs` enforces, per public route: | Budget | Limit | Measured (worst route) | Why this number | | --- | ---: | ---: | --- | | JavaScript | 200 KB | 192.1 KB | Framework floor confirmed twice, five phases apart | | CSS | 20 KB | 8.7 KB | Tailwind emits only what is used; 20 KB means it stopped | | Fonts (critical path) | 100 KB | 89.0 KB | Two variable families | | Preloaded font files | 2 | 2 | A third family is a design decision, not an accident | | HTML (wire) | 20 KB | 12.2 KB | At 1,000 records | | **HTML (uncompressed)** | **150 KB** | 116.3 KB | **The one that catches unpaginated lists** | | Total transfer | 320 KB | 300.2 KB | | | Requests | 20 | 15 | | The uncompressed-HTML limit is the important one. `/stories` at 223.7 KB would have failed it; that is precisely the bug it exists to catch. **TTFB is measured and printed but not enforced.** A Windows development box running PostgreSQL, Node and the harness on one machine says nothing reliable about a Vercel function talking to a hosted database. Asserting a number there would be asserting a number about this laptop. **LCP, CLS and TBT stay with Lighthouse**, which owns the experience budgets — see §12. --- ## 4. Server versus client rendering The site is server-first and stays that way. Every public route was audited. | Route | Rendering | Client JS of our own | Justification | | --- | --- | --- | --- | | `/` | Static (ISR 15m) | none | Server Components throughout | | `/about` | Static | none | Static prose | | `/courses` | Static (ISR 1h) | none | Reads batches server-side | | `/courses/[slug]` | SSG ×5 (ISR 1h) | none | `generateStaticParams` over published courses | | `/results` | Dynamic | none | Reads `searchParams`; see §5 | | `/stories` | Dynamic | none | Reads `searchParams`; see §5 | | `/announcements` | Static (ISR 15m) | none | Window enforced in SQL | | `/contact` | Static | none | Static prose | | `/admissions` | Dynamic | `enquiry-form.tsx` | `useActionState`; works without JS | | *(all routes)* | — | `site-header.tsx` | Mobile drawer: focus trap, Escape, `aria-modal` | **Two client components on the entire public site**, both justified: - **`site-header.tsx`** — the mobile drawer needs real interactivity, and it does the accessible version of it: Escape closes, focus moves to the close button and returns to the trigger, `aria-modal` and `aria-expanded` are set. Removing the client boundary would mean removing that. - **`enquiry-form.tsx`** — `useActionState` for inline validation, and it degrades to a plain form post without JavaScript. **No library was added.** No state library, no animation library, no carousel, no icon package — the four SVG glyphs in the header are still inline, because a dependency for four glyphs costs more than it saves. **Converting either component to a Server Component would save nothing measurable.** Phase 3 established, and Phase 9 re-confirmed, that a route with zero client components ships the same bundle: the App Router baseline is the floor. The saving would be accessibility, not bytes. --- ## 5. Rendering strategy Decided from measurement, not preference. | Route | Strategy | Reasoning | | --- | --- | --- | | `/` | ISR 15 min + `revalidatePath` | Shows the notice banner; publishing refreshes it immediately | | `/about`, `/contact` | Static | No data | | `/courses` | ISR 1 h + `revalidatePath` | Batch list | | `/courses/[slug]` | SSG + ISR 1 h | Five known slugs; batches revalidate on publish | | `/announcements` | ISR 15 min + `revalidatePath` | Window enforced in SQL | | `/results` | **Dynamic — kept, and the misleading `revalidate` removed** | See below | | `/stories` | **Static → dynamic, deliberately** | See below | | `/admissions` | Dynamic | Fresh anti-spam token per render | ### `/results` — dynamic is correct; the `revalidate` export was not Phase 8 flagged `/results` as "possibly dynamic when it need not be". Measured: it reads `searchParams`, which makes it dynamic, and it carried `export const revalidate = 3600` — **which is inert on a dynamic route**. That line had sat there for three phases stating the page was cached for an hour when it was rendered fresh every time. A comforting number describing nothing. It is removed, with a comment explaining why there is no replacement. Dynamic is the right answer: the queries execute in 0.163 ms and the render measured 19 ms against 1,000 published results. `revalidateResults()` still calls `revalidatePath('/results')`, which is a no-op for this route. It is kept — and now documented as a no-op — because `/` genuinely is cached and genuinely does show a results band, and because deleting the line would tell the next reader that results are not meant to refresh. ### `/stories` — static to dynamic, with the trade stated Pagination requires `searchParams`, which costs the ISR cache. That trade was made knowingly: | | Before | After | | --- | ---: | ---: | | Stories rendered | 60 of 80 (silently) | 12, with "Showing 12 of 80" on screen | | HTML, uncompressed | 223.7 KB | 71.1 KB | | Rendering | Static (ISR 1 h) | Dynamic, 26 ms | A cached page that omits a fifth of the content is not the faster option; it is the wrong one. An alternative — `/stories/2` as real static paths via `generateStaticParams` — would keep ISR *and* give better URLs. It is a larger change and inconsistent with `/results`; recorded in §22 as an option, not done here. ### Publishing still updates the site immediately Re-verified after every rendering change: `verify-revalidation.mjs` 9/9, `verify-integration.mjs` 47/47. A teacher publishes, the public page reflects it, no redeploy. --- ## 6. The results filter fix Phase 8 deferred: *"Year-chip counts on `/results` ignore the active programme filter."* Fixed at the query layer, and the mirror-image defect fixed with it. Each facet is now scoped to **the other** filter, never to itself: - year chips are scoped to the active **programme** — so they still list every year available for that programme, rather than collapsing to the one selected; - programme chips are scoped to the active **year**. Both grouped in the database with `_count`. Counting in JavaScript would have meant fetching all 1,000 rows to count them. Verified at 1,000 synthetic results: | Query | Year chips | Programme chips | | --- | --- | --- | | none | 250 each | 200 each | | `?programme=CLASS_11` | **50 each** | 200 each | | `?year=2025` | 250 each | **50 each** | | `?year=2020&programme=CMA` | — | *"Nothing published for that filter yet"* | | `?programme=NOT_A_PROGRAMME` | identical to unfiltered | identical to unfiltered | The counts are also exposed to assistive technology (`aria-label="2025 — 50 results"`) without changing the visible chip design. **Cost: none measurable.** A/B of the data layer over 60 runs: three queries 6.71 ms median, four queries 5.99 ms median. The added query runs inside the existing `Promise.all` and hides in the latency already there. --- ## 7. Technical SEO Every public route now carries, and is asserted to carry, a unique `