# SEO Programme — Design Date: 2026-08-20 Status: awaiting review ## Problem schoolcompare ranks second for "school compare" — an exact match for the brand and the domain. It ranks poorly for "compare schools", "school comparison" and "schools near me". The first is a naming artefact and transfers to nothing; the rest are the queries that actually carry parent demand. The cause is structural, not editorial. The site publishes five route families: / /compare /rankings /admissions /school/[slug] Location intent has no landing page at all. Every competitor outranking us on those queries wins with programmatic location pages: | Competitor | URL pattern | |---------------|--------------------------------------------| | School Guide | `/best-schools-in/manchester` | | Locrating | `/the-best-primary-schools-in-Manchester_…`| | FindMySchool | `/best-primary-schools/manchester` | | Snobe | `/best-primary-schools/manchester` | | School Atlas | `/guides/best-primary-schools-manchester` | "Schools near me" is a local-intent query. Google resolves it against the user's coordinates and serves pages that are *about a place*. A national homepage cannot win it. No title or description change fixes this; only pages Google can localise will. ## Baseline (measured 2026-08-20, production API) | Measure | Value | |--------------------------------------------|---------| | Unique schools | 27,229 | | URLs in sitemap.xml | 27,232 | | Schools with 2024/25 performance data | 21,266 | | Schools with **no** current performance data| ~5,963 (22%) | | Welsh establishments (all metrics null) | 1,569 | | Overseas / offshore establishments | 467 | | Static URLs in sitemap | 3 | | Routes setting a canonical | 1 of 5 | Three findings from that table drive the plan. **We submit ~6,000 thin pages to Google.** `build_sitemap()` (`backend/app.py:73`) enumerates every URN regardless of whether the school has any data. Welsh establishments return `school_type: "Welsh establishment"` with every performance metric, Ofsted grade and phase field null. Overseas and offshore establishments ("BFPO Overseas Establishments", "Gibraltar Overseas Establishments", "Jersey Offshore Establishments") are in the local-authority list too. At 22% of the submitted corpus this is a site-wide quality signal problem and a crawl-budget waste, not a rounding error. W1 item 4 removes 2,036 of those — every non-England establishment — taking the corpus to 25,193. The 3,927 that remain are English schools with no current data: mostly newly opened, special, nursery or alternative provision. Those are a template problem, not a corpus problem, and item 5 handles them separately. **The homepage is its own competitor.** `app/page.tsx` accepts eleven search params (`search`, `local_authority`, `school_type`, `phase`, `page`, `postcode`, `radius`, `sort`, `gender`, `admissions_policy`, `has_sixth_form`) and sets no canonical. Every filter combination is a crawlable near-duplicate of the single page we are asking to rank for "compare schools". **School pages are near-orphans.** Reachable from the sitemap and from site search, but almost nothing links to them contextually, so they accrue no internal authority. Also noted: the sitemap emits invented `priority` values and no `lastmod`. Google ignores `priority` and `changefreq` entirely; `lastmod` is the field it does read, and we omit it. ## Keyword clusters Ranked by judgement of UK parent search behaviour and by the competitive SERP evidence above. Google Search Console is connected, so cluster priorities are to be re-derived from measured impressions before build starts (see Workstream 0). **C1 — Head "compare" terms.** compare schools · school comparison · school comparison tool · compare school performance · compare primary schools · compare secondary schools · compare two schools **C2 — League tables and rankings.** primary school league tables · secondary school league tables · school league tables 2026 · SATs results by school · GCSE results by school · KS2 league tables · Progress 8 rankings · best primary schools in [town] · top 10 primary schools in [LA] **C3 — Local / near me.** schools near me · primary schools near me · secondary schools near me · best schools near me · good schools near me · schools in [town] · primary schools in [LA] · schools near [postcode] · [postcode] school catchment **C4 — Individual school long tail.** [school] ofsted · [school] SATs results · [school] catchment area · [school] reviews · [school] URN **C5 — Admissions.** primary school admissions 2027 · national offer day 2027 · school application deadline · school admissions appeal · oversubscription criteria · distance criteria school admissions · didn't get first choice school · school admissions [LA] **C6 — Metric explainers.** what is a good SATs score · what is Progress 8 · what is Attainment 8 · expected standard KS2 meaning · scaled score explained · Ofsted grades explained · Ofsted report cards · pupil premium explained **C7 — Head to head.** [school A] vs [school B] · academy vs community school · grammar school vs comprehensive · faith school vs community school ## Workstreams ### W0 — Measure before touching anything Export a Google Search Console baseline: impressions, clicks, average position and CTR by query and by page, for the trailing 16 months. Bucket queries into C1–C7. This sets the counterfactual — without it, no later claim about lift is defensible, because school-search traffic is strongly seasonal (results day in December, offer day in March/April). Re-rank C1–C7 against measured impressions and adjust the sequence below if the data disagrees with the judgement calls. ### W1 — Crawl hygiene and index sanity Cheap, and it unblocks everything after it. Adding 5,000 pages on top of a corpus that is 22% thin would compound the existing problem. 1. Canonical on every route. `/`, `/rankings`, `/compare` and `/admissions` currently set none. 2. The homepage canonicalises to `/` regardless of search params. 3. `/compare?urns=…` gets `noindex, follow` — it is an unbounded parameter space with no standalone value. 4. **England only — DONE.** Wales, the Crown Dependencies, Gibraltar and the service/overseas schools are removed from the corpus at the mart boundary, not hidden at the view layer. `dim_school` and `dim_location` both exclude `TypeOfEstablishment` in {25, 26, 30, 37} — Offshore schools, Service children's education, Welsh establishment, British schools overseas — listed once as `vars.non_england_school_type_codes` in `dbt_project.yml`. That removes 2,036 establishments and 29 local authorities, and because `build_sitemap()` reads the same marts, it drops them from the sitemap in the same stroke. `assert_england_only_schools` fails the pipeline if a GIAS refresh reintroduces them or if the two models drift apart. 5. Prune the remaining thin pages: exclude any school with no performance data **and** no Ofsted record. Distinct from item 4 — these are English schools with nothing yet to show, so the fix may be a better template rather than removal. 6. Rebuild the sitemap as a sitemap **index**: one child per page family, real `lastmod` from the data-load timestamp, `priority` and `changefreq` dropped. ### W2 — The location layer The dominant lever. `dim_location` already carries `town`, `county`, `local_authority_name`, `parliamentary_constituency`, `latitude`, `longitude` and `postcode`, so no new ingestion is required. Routes: /schools/[la] e.g. /schools/manchester /schools/[la]/primary /schools/[la]/secondary /best-primary-schools/[town] /best-secondary-schools/[town] /schools/near/[outcode] e.g. /schools/near/m20 /schools/near-me geolocating hub **Thin-page threshold: generate a town or outcode page only where at least five schools have current performance data.** Below that, 301 to the parent LA page. This is the single most important constraint in the workstream — it is what separates a location layer from index bloat. Each page must earn its place with content a parent would actually use, not a template shell: - H1 matching the query intent ("Best primary schools in Manchester") - Counts framed usefully: "137 primary schools, 9 rated Outstanding" - A ranked table of the top 20 on the headline metric - Local average against the England average - Ofsted grade distribution - Map - Links to neighbouring towns and to the parent LA - An FAQ block (feeds `FAQPage` in W4) - Links to every school page in scope — this is what de-orphans W1's corpus Sizing estimate: ~150 usable LAs × 3 ≈ 450; towns clearing the threshold ≈ 1,200 × 2 ≈ 2,400; outcodes ≈ 2,300. Roughly **5,000 new pages**, comfortably inside a sitemap index and well under the per-file 50,000 limit. ### W3 — Make rankings indexable `/rankings` is driven entirely by query params, so Google indexes approximately one page where there should be hundreds. /rankings/[phase]/[metric] /rankings/[phase]/[metric]/[la] The interactive filter UI stays; its state moves into real paths. Param forms canonicalise to the clean path. This is the direct play for C2. ### W4 — Structured data and internal linking - Replace the bare `EducationalOrganization` on school pages with `School`, and populate it properly. - `BreadcrumbList` site-wide. - `ItemList` on every rankings and location page. - `FAQPage` on admissions and on location pages. - New school-page modules: "Other schools in [town]", "Nearby schools", "Compare with similar schools". Each links out to W2 and W3 pages, which is what circulates authority instead of stranding it. Explicitly **not** doing `Dataset` or `AggregateRating` — no review corpus exists, and fabricating one would be both useless and a policy violation. ### W5 — Admissions expansion One static page currently carries an entire cluster. /admissions/[la] per-authority deadlines and offer day /admissions/appeals /admissions/national-offer-day `school admissions [LA]` is high-intent and highly seasonal; per-authority pages are the natural unit. ### W6 — Explainer content /guides/progress-8 /guides/attainment-8 /guides/sats-scaled-scores /guides/ofsted-grades /guides/expected-standard Each links into the corresponding W3 rankings page. Cheap to build, and it is what gives the metric vocabulary enough topical weight to support C1–C4. ### W7 — Head-to-head pages /compare/[school-a]-vs-[school-b] C7 is uncontested and native to the product. It is also the easiest way to destroy everything W1 fixes: 27,229 schools generate 370 million pairs. **Curated pairs only** — same town, both with current data, both with real search demand — capped in the low thousands. Gated behind evidence that W2 is indexing cleanly. ### W8 — Metadata rewrite for C1 Current homepage title is `schoolcompare | Compare every school in England`, which spends the most valuable position on the brand. Rewrite the homepage, rankings and compare titles and descriptions around C1 phrasing. Small change, and the cheapest item in the programme. ## Sequencing W0 → W1 → W2 → W3 → W4 → W5 → W6 → (W7 if W2 indexes cleanly) W1 before W2 is not negotiable: adding pages to a corpus that is 22% thin compounds the problem rather than diluting it. This spec is a programme, not a single implementation plan. Each workstream gets its own plan and its own PR; W2 will likely need several. Only W0 and W1 are ready to plan against today — the rest should be re-read after W0's Search Console baseline lands, because that data may reorder them. ## Testing Per CLAUDE.md, user-facing behaviour changes extend the `e2e/` journeys in the same PR. Each workstream adds: - W1: canonical present and correct on every route; `/compare?urns=` carries `noindex`; sitemap excludes a known dataless URN. - W2: a known LA, town and outcode page renders with the expected school count; a below-threshold town redirects to its LA. - W3: a clean rankings path renders; the param form canonicalises to it. - W4: JSON-LD parses and validates against the declared types. ## Risks **Index bloat.** The failure mode of every programmatic SEO programme. The five-school threshold, the W1 prune and the W7 gate are the three controls. **Helpful-content exposure.** Google's stance on templated location pages has hardened. The mitigation is that each page carries genuinely local computed data — real counts, real distributions, real local-vs-national comparison — rather than a name substituted into boilerplate. **Build cost.** School pages already use ISR with a 7-day revalidate and `PRERENDER_SCHOOLS` gating full prerender. 5,000 more routes need the same treatment; full static generation of 32,000 pages is likely impractical in CI. **Seasonality.** Results day and offer day dominate the traffic curve. Judging the programme on a mid-summer window would misread it in either direction. W0's baseline must be year-on-year, not month-on-month. ## Open questions 1. Catchment areas are Locrating's moat and a strong C3 driver (`[postcode] school catchment`). `fact_admissions` carries admission distances. Is deriving approximate catchment a later workstream, or out of scope?