diff --git a/e2e/tests/journeys.spec.ts b/e2e/tests/journeys.spec.ts index 94c48c5..fbe6546 100644 --- a/e2e/tests/journeys.spec.ts +++ b/e2e/tests/journeys.spec.ts @@ -1726,3 +1726,35 @@ test('a school page on staging is noindexed too, not just the homepage', async ( const res = await page.request.get(`/school/${first.urn}-x`); expect(res.headers()['x-robots-tag']).toContain('noindex'); }); + +/* + * W8 — the C1 pages must ship a description, and it must differentiate. + * + * Baseline was 0.43% CTR at position 6.1 on "compare school performance", + * against 9.16% for the brand query from the same neighbourhood. The SERP is + * owned by the DfE's own service, so a description that paraphrases it earns + * nothing. Google may rewrite a snippet, but it cannot use one we never sent. + */ +test('every C1 page ships a description, and none opens its title with the brand', async ({ page }) => { + for (const path of ['/', '/compare', '/rankings', '/admissions']) { + await page.goto(path); + + const desc = await page.locator('meta[name="description"]').first() + .getAttribute('content'); + expect(desc, `${path} must ship a description`).toBeTruthy(); + expect(desc!.length, `${path} description too short to be worth reading`) + .toBeGreaterThan(100); + + const title = await page.title(); + expect(title.toLowerCase().startsWith('schoolcompare'), + `${path} spends its most valuable pixels on the brand`).toBe(false); + } +}); + +test('the homepage snippet names what gov.uk does not publish', async ({ page }) => { + await page.goto('/'); + const desc = await page.locator('meta[name="description"]').first() + .getAttribute('content'); + // Admissions distance is the one fact the DfE service has no equivalent for. + expect(desc).toMatch(/close you had to live|distance/i); +}); diff --git a/nextjs-app/__tests__/app/metadata.test.ts b/nextjs-app/__tests__/app/metadata.test.ts index e173201..44b0d57 100644 --- a/nextjs-app/__tests__/app/metadata.test.ts +++ b/nextjs-app/__tests__/app/metadata.test.ts @@ -51,3 +51,80 @@ describe('/compare indexability', () => { .toBe('https://www.schoolcompare.co.uk/compare'); }); }); + +/* + * W8 — snippet copy for the C1 cluster. + * + * The baseline (GSC, 16 months to 2026-08-20) showed these pages ranking on + * page one and converting at a tenth of the normal rate: "compare school + * performance" at position 6.1 with 0.43% CTR, against 9.16% for the brand + * query from the same neighbourhood. The SERP is dominated by the DfE's own + * "Compare school performance" service, so the job of this copy is to say + * what that service does not offer, without losing intent match on the title. + * + * These tests guard the mechanics that make a snippet work — length, intent + * keyword, differentiator, no brand-first — not the exact wording, which + * should stay free to iterate. + */ + +// Google truncates titles near 60 characters and descriptions near 155. +const TITLE_MAX = 60; +const DESC_MIN = 110; +const DESC_MAX = 155; + +type Meta = { title?: unknown; description?: unknown }; +const titleOf = (m: Meta): string => { + const t = m.title as string | { absolute?: string } | undefined; + return typeof t === 'string' ? t : (t?.absolute ?? ''); +}; + +describe('C1 snippet copy', () => { + const pages: Array<[string, Meta, RegExp]> = [ + ['home', homeMetadata as Meta, /compare schools/i], + ['rankings', rankingsMetadata as Meta, /league table/i], + ['admissions', admissionsMetadata as Meta, /admission/i], + ]; + + for (const [name, meta, intent] of pages) { + it(`${name}: title carries the search intent and fits the SERP`, () => { + const t = titleOf(meta); + expect(t).toMatch(intent); + expect(t.length).toBeLessThanOrEqual(TITLE_MAX); + }); + + it(`${name}: title does not open with the brand`, () => { + // The measured 0.43% CTR came from a brand-first title. The most + // valuable pixels go to the thing the searcher typed. + expect(titleOf(meta).toLowerCase().startsWith('schoolcompare')).toBe(false); + }); + + it(`${name}: description is long enough to be worth reading, short enough to survive`, () => { + const d = meta.description as string; + expect(d.length).toBeGreaterThanOrEqual(DESC_MIN); + expect(d.length).toBeLessThanOrEqual(DESC_MAX); + }); + } + + it('the homepage description names what gov.uk does not publish', () => { + // Admissions distance is the one fact the DfE service has no equivalent + // for. If it ever leaves this description, the snippet is competing with + // gov.uk on gov.uk's own ground. + expect(homeMetadata.description).toMatch(/close you had to live|distance/i); + }); + + it('/compare targets the tool phrasing rather than repeating the homepage', () => { + // Two pages chasing one phrase is how a site competes with itself. + return compareMetadata({ searchParams: Promise.resolve({}) }).then((m) => { + expect(m.title).toMatch(/comparison tool/i); + expect(m.title).not.toBe(titleOf(homeMetadata as Meta)); + }); + }); + + it('no C1 page claims a school count that will drift', () => { + // The corpus moves with every data refresh; this repo has already shipped + // one copy bug of that kind ("three schools" against MAX_SCHOOLS = 5). + for (const [, meta] of pages) { + expect(meta.description as string).not.toMatch(/\b\d{2},\d{3}\b|\b\d{2},000\b/); + } + }); +}); diff --git a/nextjs-app/app/admissions/page.tsx b/nextjs-app/app/admissions/page.tsx index e3d2a67..0e7546b 100644 --- a/nextjs-app/app/admissions/page.tsx +++ b/nextjs-app/app/admissions/page.tsx @@ -5,9 +5,11 @@ import { AdmissionsView } from '@/components/AdmissionsView'; export const dynamic = 'force-static'; export const metadata: Metadata = { - title: 'School Admissions Guide', + // Deadlines and offer days are what gets searched, and what this page is + // genuinely best at — the countdowns are live. + title: { absolute: 'School Admissions Deadlines & Offer Days | schoolcompare' }, description: - 'Understand the Primary and Secondary school admissions process in England, with live countdowns to every key deadline and National Offer Day.', + 'Every key date for primary and secondary school admissions in England, with live countdowns to the application deadline and National Offer Day.', alternates: { canonical: absoluteUrl('/admissions') }, }; diff --git a/nextjs-app/app/compare/page.tsx b/nextjs-app/app/compare/page.tsx index 2d8fb22..9c7233e 100644 --- a/nextjs-app/app/compare/page.tsx +++ b/nextjs-app/app/compare/page.tsx @@ -30,9 +30,12 @@ export async function generateMetadata( const { urns } = await searchParams; const base: Metadata = { - title: 'Compare Schools', + // Deliberately not the homepage's phrase. Two pages chasing "compare + // schools" is how a site competes with itself; this one takes the tool + // phrasing instead. + title: 'School Comparison Tool — Up to Five at Once | schoolcompare', description: - 'Compare schools in England side by side — Ofsted inspections, KS2 and GCSE results against the England average, admissions odds and school community.', + 'Put up to five English schools in one table: SATs and GCSE results against the England average, Ofsted grades, and the distance places were offered.', keywords: 'school comparison, compare schools, Ofsted comparison, school admissions, KS2 comparison, primary school performance', alternates: { canonical: absoluteUrl('/compare') }, diff --git a/nextjs-app/app/layout.tsx b/nextjs-app/app/layout.tsx index 20eb8d7..2f888b9 100644 --- a/nextjs-app/app/layout.tsx +++ b/nextjs-app/app/layout.tsx @@ -48,10 +48,11 @@ export const metadata: Metadata = { statusBarStyle: 'default', }, title: { - default: 'schoolcompare | Compare School Performance', + default: 'Compare Schools Side by Side | schoolcompare', template: '%s | schoolcompare', }, - description: 'Compare primary and secondary school SATs and GCSE performance across England', + description: + 'Put five English schools on one screen — SATs, GCSE results, Ofsted grades, and how close you had to live to get a place. Free, no sign-up.', keywords: 'school comparison, KS2 results, KS4 results, primary school, secondary school, England schools, SATs results, GCSE results', authors: [{ name: 'schoolcompare' }], manifest: '/manifest.json', @@ -61,16 +62,18 @@ export const metadata: Metadata = { metadataBase: new URL(SITE_URL), openGraph: { type: 'website', - title: 'schoolcompare | Compare School Performance', - description: 'Compare primary and secondary school SATs and GCSE performance across England', + title: 'Compare Schools Side by Side | schoolcompare', + description: + 'Put five English schools on one screen — SATs, GCSE results, Ofsted grades, and how close you had to live to get a place.', url: SITE_URL, siteName: 'schoolcompare', }, twitter: { // summary_large_image now that there is an image worth showing. card: 'summary_large_image', - title: 'schoolcompare | Compare School Performance', - description: 'Compare primary and secondary school SATs and GCSE performance across England', + title: 'Compare Schools Side by Side | schoolcompare', + description: + 'Put five English schools on one screen — SATs, GCSE results, Ofsted grades, and how close you had to live to get a place.', }, }; diff --git a/nextjs-app/app/page.tsx b/nextjs-app/app/page.tsx index 750ca6e..dfcdeda 100644 --- a/nextjs-app/app/page.tsx +++ b/nextjs-app/app/page.tsx @@ -34,8 +34,21 @@ interface HomePageProps { * saying the brand twice. */ export const metadata: Metadata = { - title: { absolute: 'schoolcompare | Compare every school in England' }, - description: 'Search and compare school performance across England', + /* + * Intent in the title, differentiator in the description. + * + * These queries are owned by the DfE's own "Compare school performance" + * service, and the old title — brand first, then a near-paraphrase of that + * service's name — gave a searcher no reason to pick us over it. It drew + * 0.43% CTR at position 6.1 while the brand query drew 9.16% from the same + * neighbourhood, so the ranking was never the problem. + * + * The title now matches what people type. The description carries the one + * fact gov.uk does not publish: how close you had to live to get a place. + */ + title: { absolute: 'Compare Schools Side by Side | schoolcompare' }, + description: + 'Put five English schools on one screen — SATs, GCSE results, Ofsted grades, and how close you had to live to get a place. Free, no sign-up.', // This page reads eleven search params. They filter a result set; they do // not make a new document. Collapsing every combination onto "/" stops the // homepage competing with itself for its own head terms. diff --git a/nextjs-app/app/rankings/page.tsx b/nextjs-app/app/rankings/page.tsx index 708893a..a72d276 100644 --- a/nextjs-app/app/rankings/page.tsx +++ b/nextjs-app/app/rankings/page.tsx @@ -18,8 +18,11 @@ interface RankingsPageProps { } export const metadata: Metadata = { - title: 'School Rankings', - description: 'Top-ranked schools by SATs and GCSE performance across England', + // 'School Rankings' matched nothing anyone types. League tables is the + // phrase parents actually search, and it spikes each results day. + title: { absolute: 'Primary & Secondary School League Tables | schoolcompare' }, + description: + 'Rank English schools by SATs results, GCSEs, Progress 8 or Attainment 8, and filter by local authority or year. Built from the DfE’s own figures.', keywords: 'school rankings, top schools, best schools, KS2 rankings, KS4 rankings, school league tables', // Param forms (?metric=&local_authority=&year=&phase=) collapse here for // now. W3 replaces them with real indexable paths.