2026-08-21 17:36:12 +01:00
# W2: Location Layer Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Publish ~4,000 pages about places — towns, London localities, local
authorities and postcode districts — so the site competes for the location
intent it currently answers from position 49.5.
**Architecture:** One Python module owns the question "what places exist and
what is in each" (`backend/places.py` ), built once at startup from the same
DataFrame the sitemap uses. Two API endpoints expose it. Next App Router
renders four route families from those endpoints under ISR, and the sitemap
index gains two new children so indexation is measurable per family.
**Tech Stack:** FastAPI + pandas (registry and API), pytest with
`monkeypatch` -injected DataFrames, Next.js 15 App Router with ISR, Jest +
jsdom, Playwright for the staging gate.
**Spec:** `docs/superpowers/specs/2026-08-21-w2-location-layer-design.md`
## Global Constraints
- **Threshold: five schools with publishable data.** Below it a place is not
published and its URL 301s to the parent authority. Reuse
`_has_publishable_data` from `backend/app.py` — do not write a second
definition of "has data".
- **Per-phase thresholds apply independently.** A town with 30 primaries and
2 secondaries publishes a primary page and no secondary page.
- **No page without a local average.** If a place has too few schools with
results to compute one, it falls back to the authority.
- **Two namespaces, never one:** `/schools/[place]` for towns and localities,
`/schools/authority/[la]` for authorities. 67 town names collide with
authority names and neither set contains the other.
- **A locality slug may not collide with a viable town slug.** The registry
raises rather than silently shadowing a town.
- **Canonical host is `https://www.schoolcompare.co.uk` ** — use
`absoluteUrl()` from `@/lib/site` , never a literal.
- **Never push to `main` .** Feature branch and a PR, per `CLAUDE.md` .
- **Backend test command:**
```bash
uv run --quiet --with-requirements requirements.txt --with pytest \
--with "httpx==0.27.0" python -m pytest backend/tests -q
` ``
- **Frontend:** ` cd nextjs-app && npm test` · ` npx tsc --noEmit` · ` npx next build`
- **Do not start a local server to test** (` CLAUDE.md`).
## Deviation from the spec, and why
The spec puts the locality list in a dbt seed with a dbt test asserting every
outcode matches a school. **The backend Docker image does not contain
` pipeline/`** — it copies only ` backend/` and ` scripts/` — so the runtime
cannot read a seed file.
This plan follows the precedent the repo already set for exactly this problem:
` backend/gias_codes.py` is canonical, ` pipeline/transform/seeds/` holds a
copy, and ` assert_gias_code_names_match_seed.sql` guards drift. Localities get
` backend/localities.py` as canonical.
The dbt test is dropped rather than duplicated. Its stated purpose was to stop
a typo publishing an empty page, but the five-school threshold already makes
that impossible — a typo'd outcode matches nothing and simply does not
publish. What the threshold does not catch is a locality you *meant* to
publish silently not appearing, so Task 2 logs those at startup instead.
## File structure
| File | Responsibility |
|------|----------------|
| ` backend/places.py` (new) | The registry. What places exist, what is in each, threshold and collision rules. No HTTP, no SQL. |
| ` backend/localities.py` (new) | Curated locality → outcodes data. Data only, no logic. |
| ` backend/app.py` (modify) | Two endpoints; two new sitemap children. |
| ` nextjs-app/lib/places.ts` (new) | Typed client for the two endpoints. |
| ` nextjs-app/components/places/PlaceView.tsx` (new) | Renders one place page. Shared by all four families. |
| ` nextjs-app/app/schools/[place]/...` (new) | Place and phase routes. |
| ` nextjs-app/app/schools/authority/[la]/...` (new) | Authority routes. |
| ` nextjs-app/app/schools/near/[outcode]/page.tsx` (new) | Outcode routes. |
` PlaceView` is deliberately one component for all four families: they differ
in what fills the registry, not in what the page shows. A second component
would be two places to forget the same change.
---
### Task 1: The place registry — towns and authorities
**Files:**
- Create: ` backend/places.py`
- Test: ` backend/tests/test_places.py`
**Interfaces:**
- Consumes: ` _has_publishable_data` and ` _slugify` from ` backend.app`.
- Produces:
` ``python
@dataclass(frozen=True)
class Place:
kind: str # "town" | "locality" | "authority" | "outcode"
slug: str
name: str
urns: tuple[int, ...]
parent_authority: str | None
MIN_SCHOOLS: int = 5
def build_place_registry(df) -> dict[str, Place]
` ``
Keys are ` "<kind>:<slug>"` — ` "town:brentwood"`, ` "authority:essex"` — so
the two namespaces cannot collide in the dict either.
- [ ] **Step 1: Write the failing tests**
Create ` backend/tests/test_places.py`:
` ``python
"""Tests for the place registry (spec 2026-08-21).
The registry is built from the in-memory school DataFrame, so these build a
small frame directly rather than touching a database.
"""
import numpy as np
import pandas as pd
import pytest
from backend.places import MIN_SCHOOLS, build_place_registry
def _df(rows: list[dict]) -> pd.DataFrame:
base = {
"year": 202425, "ofsted_grade": 2.0, "ofsted_date": None,
"rwm_expected_pct": 60.0, "attainment_8_score": np.nan,
"phase": "Primary", "postcode": "AA1 1AA",
}
return pd.DataFrame([{**base, **r} for r in rows])
2026-08-21 18:10:37 +01:00
def _town(n: int, town: str, la: str, start: int = 100000, **kw) -> list[dict]:
"""` start` offsets the URNs so two calls can describe different schools —
the Bedford case needs two authorities' worth of distinct URNs in one
town."""
2026-08-21 17:36:12 +01:00
return [
2026-08-21 18:10:37 +01:00
{"urn": start + i, "school_name": f"{town} School {i}",
2026-08-21 17:36:12 +01:00
"town": town, "local_authority": la, **kw}
for i in range(n)
]
def test_town_clearing_the_threshold_is_published():
reg = build_place_registry(_df(_town(MIN_SCHOOLS, "Brentwood", "Essex")))
assert "town:brentwood" in reg
assert reg["town:brentwood"].name == "Brentwood"
assert len(reg["town:brentwood"].urns) == MIN_SCHOOLS
def test_town_below_the_threshold_is_not_published():
reg = build_place_registry(_df(_town(MIN_SCHOOLS - 1, "Crosby", "Sefton")))
assert "town:crosby" not in reg
def test_a_town_below_threshold_still_names_its_authority():
# The route layer needs somewhere to 301 to.
reg = build_place_registry(_df(
_town(MIN_SCHOOLS - 1, "Crosby", "Sefton") + _town(MIN_SCHOOLS, "Bootle", "Sefton")))
assert "authority:sefton" in reg
def test_town_and_authority_of_the_same_name_are_separate_places():
# 67 real collisions. Neither set contains the other: Bedford the town has
# 104 schools, Bedford the authority 86, because postal towns cross
# authority boundaries.
rows = (_town(MIN_SCHOOLS, "Bedford", "Bedford")
2026-08-21 18:10:37 +01:00
+ _town(MIN_SCHOOLS, "Bedford", "Central Bedfordshire", start=200000))
2026-08-21 17:36:12 +01:00
reg = build_place_registry(_df(rows))
town, authority = reg["town:bedford"], reg["authority:bedford"]
assert set(town.urns) != set(authority.urns)
assert len(town.urns) == MIN_SCHOOLS * 2 # both authorities' schools
assert len(authority.urns) == MIN_SCHOOLS # only this authority's
def test_schools_without_publishable_data_do_not_count_toward_the_threshold():
rows = _town(MIN_SCHOOLS, "Ghosttown", "Nowhere")
for r in rows:
r["rwm_expected_pct"] = np.nan
r["ofsted_grade"] = np.nan
reg = build_place_registry(_df(rows))
assert "town:ghosttown" not in reg
def test_blank_town_is_ignored():
rows = _town(MIN_SCHOOLS, "", "Essex")
reg = build_place_registry(_df(rows))
assert not any(k.startswith("town:") for k in reg)
def test_a_school_is_counted_once_even_with_several_years_of_rows():
rows = []
for year in (202324, 202425):
rows += [{**r, "year": year} for r in _town(MIN_SCHOOLS, "Beccles", "Suffolk")]
reg = build_place_registry(_df(rows))
assert len(reg["town:beccles"].urns) == MIN_SCHOOLS
` ``
- [ ] **Step 2: Run the tests to verify they fail**
Run:
` ``bash
uv run --quiet --with-requirements requirements.txt --with pytest \
--with "httpx==0.27.0" python -m pytest backend/tests/test_places.py -q
` ``
Expected: FAIL — ` No module named 'backend.places'`.
- [ ] **Step 3: Write the registry**
Create ` backend/places.py`:
` ``python
"""The place registry: what places the site publishes, and what is in each.
One module owns this question. The pages, the sitemap and the internal-link
modules all read from here, so the threshold and the collision rules exist in
exactly one place and are testable without a browser or a database.
Two namespaces, never one. 67 viable town names collide with a local
authority name, and the authority is the larger set in only 43 of them —
postal towns cross authority boundaries, so neither can absorb the other.
Keys are "<kind>:<slug>" so the collision cannot reappear in the dict.
"""
from __future__ import annotations
from dataclasses import dataclass
# Five schools with publishable data. Below this a place has nothing to say
# that a list of schools does not, and publishing it is index bloat.
MIN_SCHOOLS = 5
@dataclass(frozen=True)
class Place:
kind: str # "town" | "locality" | "authority" | "outcode"
slug: str
name: str
urns: tuple[int, ...]
parent_authority: str | None # authority NAME, for the 301 target
@property
def key(self) -> str:
return f"{self.kind}:{self.slug}"
def _publishable_urns(df):
"""URNs with something a page could state, deduplicated across years."""
from backend.app import _PUBLISHABLE_FIELDS, _slugify # noqa: F401
cols = [c for c in _PUBLISHABLE_FIELDS if c in df.columns]
if not cols:
return set()
return set(df.loc[df[cols].notna().any(axis=1), "urn"].astype(int))
def _group(df, column: str, kind: str, publishable: set[int]) -> dict[str, Place]:
"""One Place per distinct value of ` column` that clears the threshold."""
from backend.app import _slugify
if column not in df.columns:
return {}
out: dict[str, Place] = {}
for name, group in df.groupby(column, dropna=True):
name = str(name).strip()
if not name:
continue
urns = tuple(sorted({int(u) for u in group["urn"]} & publishable))
if len(urns) < MIN_SCHOOLS:
continue
slug = _slugify(name)
if not slug:
continue
# A town spanning several authorities has no single parent; the most
# common one is the useful 301 target.
parent = None
if kind == "town" and "local_authority" in group.columns:
top = group["local_authority"].dropna()
parent = str(top.mode().iloc[0]) if not top.empty else None
place = Place(kind=kind, slug=slug, name=name, urns=urns,
parent_authority=parent)
out[place.key] = place
return out
def build_place_registry(df) -> dict[str, Place]:
"""Every place the site publishes, keyed by "<kind>:<slug>"."""
if df.empty or "urn" not in df.columns:
return {}
publishable = _publishable_urns(df)
registry: dict[str, Place] = {}
registry.update(_group(df, "local_authority", "authority", publishable))
registry.update(_group(df, "town", "town", publishable))
return registry
` ``
- [ ] **Step 4: Run the tests to verify they pass**
Run:
` ``bash
uv run --quiet --with-requirements requirements.txt --with pytest \
--with "httpx==0.27.0" python -m pytest backend/tests/test_places.py -q
` ``
Expected: PASS (7 tests).
- [ ] **Step 5: Commit**
` ``bash
git add backend/places.py backend/tests/test_places.py
git commit -m "feat(places): registry of towns and authorities
Two namespaces because 67 town names collide with an authority name and
neither set contains the other — postal towns cross authority boundaries."
` ``
---
### Task 2: The registry — London localities and outcodes
**Files:**
- Create: ` backend/localities.py`
- Create: ` pipeline/transform/seeds/locality_outcodes.csv`
- Modify: ` backend/places.py` (add locality and outcode groups)
- Test: ` backend/tests/test_places.py` (extend)
**Interfaces:**
- Consumes: ` Place`, ` MIN_SCHOOLS`, ` build_place_registry` (Task 1).
- Produces:
` ``python
# backend/localities.py
LOCALITY_OUTCODES: dict[str, tuple[str, tuple[str, ...]]]
# slug -> (display name, outcodes)
` ``
` build_place_registry` gains ` "locality:*"` and ` "outcode:*"` keys.
- [ ] **Step 1: Write the failing tests**
Append to ` backend/tests/test_places.py`:
` ``python
def test_locality_groups_schools_by_outcode(monkeypatch):
# The GIAS town field collapses 1,819 London schools into "London", so a
# locality is defined by its postcode districts instead.
from backend import localities
monkeypatch.setattr(localities, "LOCALITY_OUTCODES",
{"battersea": ("Battersea", ("SW11",))})
rows = _town(MIN_SCHOOLS, "London", "Wandsworth")
for r in rows:
r["postcode"] = "SW11 2AA"
reg = build_place_registry(_df(rows))
assert reg["locality:battersea"].name == "Battersea"
assert len(reg["locality:battersea"].urns) == MIN_SCHOOLS
def test_locality_below_the_threshold_is_not_published(monkeypatch):
from backend import localities
monkeypatch.setattr(localities, "LOCALITY_OUTCODES",
{"nowhere": ("Nowhere", ("ZZ99",))})
reg = build_place_registry(_df(_town(MIN_SCHOOLS, "London", "Wandsworth")))
assert "locality:nowhere" not in reg
def test_a_locality_may_not_shadow_a_viable_town(monkeypatch):
# Silently shadowing a town would lose a page carrying real demand.
from backend import localities
monkeypatch.setattr(localities, "LOCALITY_OUTCODES",
{"brentwood": ("Brentwood", ("CM13",))})
rows = _town(MIN_SCHOOLS, "Brentwood", "Essex")
for r in rows:
r["postcode"] = "CM13 1AA"
with pytest.raises(ValueError, match="brentwood"):
build_place_registry(_df(rows))
def test_outcode_places_are_built_from_postcodes():
rows = _town(MIN_SCHOOLS, "Brentwood", "Essex")
for r in rows:
r["postcode"] = "CM13 1AA"
reg = build_place_registry(_df(rows))
assert reg["outcode:cm13"].name == "CM13"
assert len(reg["outcode:cm13"].urns) == MIN_SCHOOLS
def test_malformed_postcodes_do_not_create_places():
rows = _town(MIN_SCHOOLS, "Brentwood", "Essex")
for r in rows:
r["postcode"] = "not a postcode"
reg = build_place_registry(_df(rows))
assert not any(k.startswith("outcode:") for k in reg)
def test_every_curated_locality_is_structurally_valid():
# Guards the hand-maintained file: real slug, real name, real outcodes.
import re
from backend.localities import LOCALITY_OUTCODES
assert LOCALITY_OUTCODES, "the curated locality list must not be empty"
for slug, (name, outcodes) in LOCALITY_OUTCODES.items():
assert re.fullmatch(r"[a-z0-9-]+", slug), slug
assert name.strip() == name and name, slug
assert outcodes, f"{slug} has no outcodes"
for oc in outcodes:
assert re.fullmatch(r"[A-Z]{1,2}\d{1,2}[A-Z]?", oc), (slug, oc)
` ``
- [ ] **Step 2: Run the tests to verify they fail**
Run:
` ``bash
uv run --quiet --with-requirements requirements.txt --with pytest \
--with "httpx==0.27.0" python -m pytest backend/tests/test_places.py -q
` ``
Expected: FAIL — ` No module named 'backend.localities'`.
- [ ] **Step 3: Write the curated locality data**
Create ` backend/localities.py`:
` ``python
"""Curated London localities, defined by the postcode districts they cover.
The GIAS ` town` field puts 1,819 London schools under the single value
"London", so it cannot answer "schools in Battersea" — a query that appears in
the Search Console baseline. No single field can: parliamentary constituency
gives Battersea but not Canary Wharf; postcodes.io's admin_ward gives Canary
Wharf but not Battersea; neither gives Clapham or Shoreditch, which are postal
and colloquial rather than administrative.
So this is curated. Where a locality ends is a judgement, not a fact, and a
reviewable file is the honest place for a judgement. No new ingestion is
needed — the corpus already carries postcodes.
This is the canonical copy. ` pipeline/transform/seeds/locality_outcodes.csv`
mirrors it for anyone querying the warehouse directly; the backend image does
not contain ` pipeline/`, which is why the module rather than the seed is
canonical. Same arrangement as ` backend/gias_codes.py`.
A locality whose outcodes hold fewer than MIN_SCHOOLS schools is not
published, so a typo produces no page rather than an empty one. Places that
fail that check are logged at startup, because a locality you meant to publish
quietly not appearing is the failure worth hearing about.
"""
# slug -> (display name, outcodes)
LOCALITY_OUTCODES: dict[str, tuple[str, tuple[str, ...]]] = {
"battersea": ("Battersea", ("SW11",)),
"canary-wharf": ("Canary Wharf", ("E14",)),
"clapham": ("Clapham", ("SW4",)),
"shoreditch": ("Shoreditch", ("EC2A", "E1")),
"peckham": ("Peckham", ("SE15",)),
"brixton": ("Brixton", ("SW2", "SW9")),
"hackney": ("Hackney", ("E5", "E8", "E9")),
"islington": ("Islington", ("N1", "N5", "N7")),
"camden-town": ("Camden Town", ("NW1",)),
"greenwich": ("Greenwich", ("SE10",)),
"wimbledon": ("Wimbledon", ("SW19",)),
"putney": ("Putney", ("SW15",)),
"fulham": ("Fulham", ("SW6",)),
"chiswick": ("Chiswick", ("W4",)),
"ealing": ("Ealing", ("W5", "W13")),
"richmond": ("Richmond", ("TW9", "TW10")),
"stratford": ("Stratford", ("E15",)),
"walthamstow": ("Walthamstow", ("E17",)),
"tooting": ("Tooting", ("SW17",)),
"dulwich": ("Dulwich", ("SE21", "SE22")),
}
` ``
Create ` pipeline/transform/seeds/locality_outcodes.csv` mirroring it, so the
warehouse can join on the same definitions:
` ``csv
locality_slug,locality_name,outcodes,region
battersea,Battersea,SW11,London
canary-wharf,Canary Wharf,E14,London
clapham,Clapham,SW4,London
shoreditch,Shoreditch,EC2A|E1,London
peckham,Peckham,SE15,London
brixton,Brixton,SW2|SW9,London
hackney,Hackney,E5|E8|E9,London
islington,Islington,N1|N5|N7,London
camden-town,Camden Town,NW1,London
greenwich,Greenwich,SE10,London
wimbledon,Wimbledon,SW19,London
putney,Putney,SW15,London
fulham,Fulham,SW6,London
chiswick,Chiswick,W4,London
ealing,Ealing,W5|W13,London
richmond,Richmond,TW9|TW10,London
stratford,Stratford,E15,London
walthamstow,Walthamstow,E17,London
tooting,Tooting,SW17,London
dulwich,Dulwich,SE21|SE22,London
` ``
- [ ] **Step 4: Extend the registry**
In ` backend/places.py`, add above ` build_place_registry`:
` ``python
import logging
import re
logger = logging.getLogger(__name__)
# "SW11 2AA" -> "SW11". Two letters max, one or two digits, optional letter.
_OUTCODE_RE = re.compile(r"^([A-Z]{1,2}\d{1,2}[A-Z]?)\s")
def _outcode(postcode) -> str | None:
if not isinstance(postcode, str):
return None
m = _OUTCODE_RE.match(postcode.upper().strip())
return m.group(1) if m else None
def _outcode_places(df, publishable: set[int]) -> dict[str, Place]:
if "postcode" not in df.columns:
return {}
working = df.assign(_oc=df["postcode"].map(_outcode))
working = working[working["_oc"].notna()]
out: dict[str, Place] = {}
for oc, group in working.groupby("_oc"):
urns = tuple(sorted({int(u) for u in group["urn"]} & publishable))
if len(urns) < MIN_SCHOOLS:
continue
parent = None
if "local_authority" in group.columns:
top = group["local_authority"].dropna()
parent = str(top.mode().iloc[0]) if not top.empty else None
place = Place(kind="outcode", slug=str(oc).lower(), name=str(oc),
urns=urns, parent_authority=parent)
out[place.key] = place
return out
def _locality_places(df, publishable: set[int],
town_slugs: set[str]) -> dict[str, Place]:
from backend.localities import LOCALITY_OUTCODES
if "postcode" not in df.columns:
return {}
working = df.assign(_oc=df["postcode"].map(_outcode))
out: dict[str, Place] = {}
for slug, (name, outcodes) in LOCALITY_OUTCODES.items():
if slug in town_slugs:
raise ValueError(
f"locality {slug!r} collides with a published town of the same "
"slug; publishing both would shadow the town silently"
)
group = working[working["_oc"].isin(outcodes)]
urns = tuple(sorted({int(u) for u in group["urn"]} & publishable))
if len(urns) < MIN_SCHOOLS:
logger.warning(
"locality %s (%s) has %d publishable schools, below the "
"threshold of %d — not published",
slug, ", ".join(outcodes), len(urns), MIN_SCHOOLS)
continue
parent = None
if "local_authority" in group.columns:
top = group["local_authority"].dropna()
parent = str(top.mode().iloc[0]) if not top.empty else None
place = Place(kind="locality", slug=slug, name=name, urns=urns,
parent_authority=parent)
out[place.key] = place
return out
` ``
Replace the body of ` build_place_registry` with:
` ``python
def build_place_registry(df) -> dict[str, Place]:
"""Every place the site publishes, keyed by "<kind>:<slug>"."""
if df.empty or "urn" not in df.columns:
return {}
publishable = _publishable_urns(df)
registry: dict[str, Place] = {}
registry.update(_group(df, "local_authority", "authority", publishable))
towns = _group(df, "town", "town", publishable)
registry.update(towns)
town_slugs = {p.slug for p in towns.values()}
registry.update(_locality_places(df, publishable, town_slugs))
registry.update(_outcode_places(df, publishable))
return registry
` ``
- [ ] **Step 5: Run the tests to verify they pass**
Run:
` ``bash
uv run --quiet --with-requirements requirements.txt --with pytest \
--with "httpx==0.27.0" python -m pytest backend/tests/test_places.py -q
` ``
Expected: PASS (13 tests).
- [ ] **Step 6: Commit**
` ``bash
git add backend/localities.py backend/places.py backend/tests/test_places.py \
pipeline/transform/seeds/locality_outcodes.csv
git commit -m "feat(places): London localities and postcode districts
The GIAS town field puts 1,819 London schools under one value, so a locality
is defined by the postcode districts it covers. Curated, because where a
locality ends is a judgement rather than a fact."
` ``
---
### Task 3: The places API
**Files:**
- Modify: ` backend/app.py` (registry cache, two endpoints)
- Test: ` backend/tests/test_places_api.py`
**Interfaces:**
- Consumes: ` build_place_registry`, ` Place` (Tasks 1– 2).
- Produces:
` ``
GET /api/places -> {"places": [{slug, kind, name, count}]}
GET /api/places/{kind}/{slug} -> {"place": {...}, "schools": [...],
"averages": {...}}
` ``
` get_place_registry()` returns the cached ` dict[str, Place]` and is what
Task 4's sitemap enumerates.
- [ ] **Step 1: Write the failing tests**
Create ` backend/tests/test_places_api.py`:
` ``python
"""Tests for the places API (spec 2026-08-21)."""
import numpy as np
import pandas as pd
import pytest
from fastapi.testclient import TestClient
def _schools_df() -> pd.DataFrame:
base = {
"local_authority": "Essex", "school_type": "Academy",
"phase": "Primary", "year": 202425, "ofsted_grade": 2.0,
"ofsted_date": None, "attainment_8_score": np.nan,
"town": "Brentwood", "postcode": "CM13 1AA", "status": "Open",
"address": "1 Test Street", "latitude": 51.6, "longitude": 0.3,
}
return pd.DataFrame([
{**base, "urn": 100000 + i, "school_name": f"Brentwood School {i}",
"rwm_expected_pct": 50.0 + i}
for i in range(6)
])
@pytest.fixture()
def client(monkeypatch):
from backend import app as app_module
monkeypatch.setattr(app_module, "load_school_data", _schools_df)
monkeypatch.setattr(app_module, "load_latest_school_data", _schools_df)
monkeypatch.setattr(app_module, "_place_registry", None)
return TestClient(app_module.app, raise_server_exceptions=False)
def test_registry_lists_each_published_place(client):
body = client.get("/api/places").json()
slugs = {(p["kind"], p["slug"]) for p in body["places"]}
assert ("town", "brentwood") in slugs
assert ("authority", "essex") in slugs
assert ("outcode", "cm13") in slugs
def test_registry_carries_a_count_per_place(client):
body = client.get("/api/places").json()
town = next(p for p in body["places"] if p["slug"] == "brentwood")
assert town["count"] == 6
def test_place_detail_returns_its_schools_ranked(client):
body = client.get("/api/places/town/brentwood").json()
assert body["place"]["name"] == "Brentwood"
scores = [s["rwm_expected_pct"] for s in body["schools"]]
assert scores == sorted(scores, reverse=True)
def test_place_detail_carries_the_local_average(client):
body = client.get("/api/places/town/brentwood").json()
# 50..55 inclusive
assert body["averages"]["rwm_expected_pct"] == pytest.approx(52.5)
def test_phase_filter_narrows_the_school_list(client):
body = client.get("/api/places/town/brentwood?phase=secondary").json()
assert body["schools"] == []
def test_unknown_place_404s(client):
assert client.get("/api/places/town/atlantis").status_code == 404
def test_unknown_kind_404s(client):
assert client.get("/api/places/planet/mars").status_code == 404
` ``
- [ ] **Step 2: Run the tests to verify they fail**
Run:
` ``bash
uv run --quiet --with-requirements requirements.txt --with pytest \
--with "httpx==0.27.0" python -m pytest backend/tests/test_places_api.py -q
` ``
Expected: FAIL — 404 on every route.
- [ ] **Step 3: Add the cache and endpoints**
In ` backend/app.py`, beside ` _sitemaps`:
` ``python
# Built once from the same DataFrame the sitemap uses, and rebuilt by
# /api/admin/regenerate-sitemap after a pipeline run.
_place_registry: dict | None = None
` ``
Add near the other helpers:
` ``python
from .places import Place, build_place_registry # noqa: E402
VALID_PLACE_KINDS = ("town", "locality", "authority", "outcode")
def get_place_registry() -> dict:
global _place_registry
if _place_registry is None:
_place_registry = build_place_registry(load_school_data())
return _place_registry
` ``
Add the endpoints beside the other ` /api` routes:
` ``python
@app.get("/api/places")
@limiter.limit(f"{settings.rate_limit_per_minute}/minute")
async def list_places(request: Request):
"""Every published place. The sitemap and the link modules read this."""
registry = get_place_registry()
return {"places": [
{"kind": p.kind, "slug": p.slug, "name": p.name, "count": len(p.urns)}
for p in sorted(registry.values(), key=lambda p: (p.kind, p.slug))
]}
@app.get("/api/places/{kind}/{slug}")
@limiter.limit(f"{settings.rate_limit_per_minute}/minute")
async def get_place(request: Request, kind: str, slug: str,
phase: Optional[str] = None):
"""One place: its schools ranked, and its local averages."""
if kind not in VALID_PLACE_KINDS:
raise HTTPException(status_code=404, detail="No such place")
place = get_place_registry().get(f"{kind}:{slug}")
if place is None:
raise HTTPException(status_code=404, detail="No such place")
df = load_latest_school_data()
rows = df[df["urn"].isin(place.urns)]
if phase:
wanted = PHASE_GROUPS.get(phase.lower())
if wanted and "phase" in rows.columns:
rows = rows[rows["phase"].str.lower().isin(wanted)]
metric = "attainment_8_score" if phase == "secondary" else "rwm_expected_pct"
if metric in rows.columns:
rows = rows.sort_values(metric, ascending=False, na_position="last")
averages = {
m: (None if m not in rows.columns or rows[m].dropna().empty
else float(rows[m].dropna().mean()))
for m in ("rwm_expected_pct", "attainment_8_score")
}
return {
"place": {"kind": place.kind, "slug": place.slug, "name": place.name,
"count": len(place.urns),
"parent_authority": place.parent_authority},
"schools": df_to_records(rows, SCHOOL_COLUMNS),
"averages": averages,
}
` ``
If ` df_to_records` is not already imported in ` app.py`, use the same
serialisation helper the ` /api/schools` route uses — match that route rather
than inventing a second shape, so the frontend's ` School` type covers both.
- [ ] **Step 4: Run the tests to verify they pass**
Run:
` ``bash
uv run --quiet --with-requirements requirements.txt --with pytest \
--with "httpx==0.27.0" python -m pytest backend/tests/test_places_api.py -q
` ``
Expected: PASS (7 tests).
- [ ] **Step 5: Rebuild the registry when the sitemap regenerates**
In ` /api/admin/regenerate-sitemap`, add ` _place_registry` to the globals and
reset it, so a pipeline run refreshes places and sitemap together:
` ``python
global _sitemaps, _place_registry
_place_registry = None
_sitemaps = build_sitemaps()
` ``
- [ ] **Step 6: Run the whole backend suite**
Run:
` ``bash
uv run --quiet --with-requirements requirements.txt --with pytest \
--with "httpx==0.27.0" python -m pytest backend/tests -q
` ``
Expected: PASS. Baseline before this plan was 70.
- [ ] **Step 7: Commit**
` ``bash
git add backend/app.py backend/tests/test_places_api.py
git commit -m "feat(places): /api/places registry and place detail endpoints"
` ``
---
### Task 4: Place sitemaps
**Files:**
- Modify: ` backend/app.py` (` build_sitemaps`)
- Test: ` backend/tests/test_sitemap.py` (extend)
**Interfaces:**
- Consumes: ` get_place_registry()` (Task 3), ` _url_element`, ` _urlset`,
` SITEMAP_CHUNK_SIZE` (existing).
- Produces: ` places-{n}.xml` and ` outcodes-{n}.xml` children in the index.
- [ ] **Step 1: Write the failing tests**
Append to ` backend/tests/test_sitemap.py`:
` ``python
def _places_df() -> pd.DataFrame:
base = {
"local_authority": "Essex", "school_type": "Academy",
"phase": "Primary", "year": 202425, "ofsted_grade": 2.0,
"ofsted_date": None, "attainment_8_score": np.nan,
"town": "Brentwood", "postcode": "CM13 1AA",
}
return pd.DataFrame([
{**base, "urn": 100000 + i, "school_name": f"Brentwood School {i}",
"rwm_expected_pct": 60.0}
for i in range(6)
])
@pytest.fixture()
def place_sitemaps(monkeypatch) -> dict:
from backend import app as app_module
monkeypatch.setattr(app_module, "load_school_data", _places_df)
monkeypatch.setattr(app_module, "_place_registry", None)
return app_module.build_sitemaps()
def test_place_children_are_listed_in_the_index(place_sitemaps):
index = place_sitemaps["sitemap.xml"]
assert "/sitemaps/places-1.xml" in index
assert "/sitemaps/outcodes-1.xml" in index
def test_town_and_authority_urls_use_their_own_namespaces(place_sitemaps):
xml = place_sitemaps["places-1.xml"]
assert "<loc>https://www.schoolcompare.co.uk/schools/brentwood</loc>" in xml
assert "<loc>https://www.schoolcompare.co.uk/schools/authority/essex</loc>" in xml
def test_outcode_urls_live_in_their_own_child(place_sitemaps):
assert "/schools/near/cm13" in place_sitemaps["outcodes-1.xml"]
assert "/schools/near/cm13" not in place_sitemaps["places-1.xml"]
def test_place_urls_carry_no_priority_or_changefreq(place_sitemaps):
for name in ("places-1.xml", "outcodes-1.xml"):
assert "<priority>" not in place_sitemaps[name]
assert "<changefreq>" not in place_sitemaps[name]
` ``
- [ ] **Step 2: Run the tests to verify they fail**
Run:
` ``bash
uv run --quiet --with-requirements requirements.txt --with pytest \
--with "httpx==0.27.0" python -m pytest backend/tests/test_sitemap.py -q
` ``
Expected: FAIL — ` KeyError: 'places-1.xml'`.
- [ ] **Step 3: Emit the place children**
In ` backend/app.py`, add above ` build_sitemaps`:
` ``python
def _place_url(place) -> str:
"""The canonical path for a place. Two namespaces, per the spec."""
if place.kind == "authority":
return f"/schools/authority/{place.slug}"
if place.kind == "outcode":
return f"/schools/near/{place.slug}"
return f"/schools/{place.slug}" # town and locality share one
def _place_sitemap_rows(kinds: tuple[str, ...]) -> list[str]:
return [
_url_element(BASE_URL + _place_url(p))
for p in sorted(get_place_registry().values(),
key=lambda p: (p.kind, p.slug))
if p.kind in kinds
]
` ``
Inside ` build_sitemaps`, after the school children are added:
` ``python
# Separate children per family: Search Console reports coverage per
# submitted sitemap, which is how the location layer's indexation is
# measured apart from the school pages'.
for label, kinds in (("places", ("town", "locality", "authority")),
("outcodes", ("outcode",))):
rows = _place_sitemap_rows(kinds)
chunks = [rows[i:i + SITEMAP_CHUNK_SIZE]
for i in range(0, len(rows), SITEMAP_CHUNK_SIZE)] or [[]]
for n, chunk in enumerate(chunks, start=1):
children[f"{label}-{n}.xml"] = _urlset(chunk)
` ``
- [ ] **Step 4: Run the tests to verify they pass**
Run:
` ``bash
uv run --quiet --with-requirements requirements.txt --with pytest \
--with "httpx==0.27.0" python -m pytest backend/tests/test_sitemap.py -q
` ``
Expected: PASS.
- [ ] **Step 5: Widen the Next sitemap proxy's allowlist**
` nextjs-app/app/sitemaps/[...parts]/route.ts` validates the child name against
a regex that only knows the school families. Replace it:
` ``typescript
const CHILD = /^(static|schools-\d+|places-\d+|outcodes-\d+)\.xml$/;
` ``
- [ ] **Step 6: Run the whole backend suite and commit**
` ``bash
uv run --quiet --with-requirements requirements.txt --with pytest \
--with "httpx==0.27.0" python -m pytest backend/tests -q
git add backend/app.py backend/tests/test_sitemap.py \
"nextjs-app/app/sitemaps/[...parts]/route.ts"
git commit -m "feat(places): submit place and outcode sitemaps
Separate children per family so Search Console reports the location layer's
indexation apart from the school pages'."
` ``
---
### Task 5: The place page
**Files:**
- Create: ` nextjs-app/lib/places.ts`
- Create: ` nextjs-app/components/places/PlaceView.tsx`
- Create: ` nextjs-app/components/places/PlaceView.module.css`
- Test: ` nextjs-app/__tests__/components/PlaceView.test.tsx`
**Interfaces:**
- Consumes: ` /api/places/{kind}/{slug}` (Task 3), ` absoluteUrl` from ` @/lib/site `.
- Produces:
` ``typescript
// lib/places.ts
export interface PlaceSummary { kind: string; slug: string; name: string; count: number }
export interface PlaceDetail {
place: PlaceSummary & { parent_authority: string | null };
schools: School[];
averages: { rwm_expected_pct: number | null; attainment_8_score: number | null };
}
export function placeUrl(kind: string, slug: string, phase?: string): string
export async function fetchPlaces(): Promise<PlaceSummary[]>
export async function fetchPlace(kind: string, slug: string, phase?: string): Promise<PlaceDetail | null>
` ``
- [ ] **Step 1: Write the failing tests**
Create ` nextjs-app/__tests__/components/PlaceView.test.tsx`:
` ``tsx
import { render, screen } from '@testing-library/react';
import { PlaceView } from '@/components/places/PlaceView';
import type { PlaceDetail } from '@/lib/places';
const detail: PlaceDetail = {
place: { kind: 'town', slug: 'brentwood', name: 'Brentwood', count: 29,
parent_authority: 'Essex' },
schools: [
{ urn: 1, school_name: 'Alpha Primary', rwm_expected_pct: 82,
ofsted_grade: 1 } as never,
{ urn: 2, school_name: 'Beta Primary', rwm_expected_pct: 44,
ofsted_grade: 3 } as never,
],
averages: { rwm_expected_pct: 63, attainment_8_score: null },
};
describe('PlaceView', () => {
it('leads with an H1 that matches how the place is searched', () => {
render(<PlaceView detail={detail} phase="primary" englandAverage={61}
neighbours={[]} />);
expect(screen.getByRole('heading', { level: 1 }))
.toHaveTextContent(/primary schools in brentwood/i);
});
it('states the count so the page says something before the table', () => {
render(<PlaceView detail={detail} phase="primary" englandAverage={61}
neighbours={[]} />);
expect(screen.getByText(/29 schools/i)).toBeInTheDocument();
});
it('compares the local average against England, which a list cannot', () => {
render(<PlaceView detail={detail} phase="primary" englandAverage={61}
neighbours={[]} />);
expect(screen.getByTestId('local-vs-england')).toHaveTextContent('63');
expect(screen.getByTestId('local-vs-england')).toHaveTextContent('61');
});
it('links every school in scope, which is what de-orphans them', () => {
render(<PlaceView detail={detail} phase="primary" englandAverage={61}
neighbours={[]} />);
expect(screen.getAllByRole('link', { name: /Primary$/ })).toHaveLength(2);
});
it('links to the parent authority so the place sits in a hierarchy', () => {
render(<PlaceView detail={detail} phase="primary" englandAverage={61}
neighbours={[]} />);
expect(screen.getByRole('link', { name: /Essex/i }))
.toHaveAttribute('href', '/schools/authority/essex');
});
it('shows the Ofsted distribution, not just a count of Outstanding', () => {
render(<PlaceView detail={detail} phase="primary" englandAverage={61}
neighbours={[]} />);
expect(screen.getByTestId('ofsted-distribution')).toBeInTheDocument();
});
it('links to neighbouring places so the page is not a dead end', () => {
render(<PlaceView detail={detail} phase="primary" englandAverage={61}
neighbours={[{ kind: 'town', slug: 'romford', name: 'Romford', count: 40 }]} />);
expect(screen.getByRole('link', { name: /Romford/ }))
.toHaveAttribute('href', '/schools/romford');
});
it('says nothing about an average it does not have', () => {
render(<PlaceView detail={{ ...detail, averages:
{ rwm_expected_pct: null, attainment_8_score: null } }}
phase="primary" englandAverage={61} neighbours={[]} />);
expect(screen.queryByTestId('local-vs-england')).not.toBeInTheDocument();
});
});
` ``
- [ ] **Step 2: Run the tests to verify they fail**
Run: ` cd nextjs-app && npm test -- __tests__ /components/PlaceView.test.tsx`
Expected: FAIL — ` Cannot find module '@/components/places/PlaceView '`.
- [ ] **Step 3: Write the client**
Create ` nextjs-app/lib/places.ts`:
` ``typescript
/**
* Client for the places API.
*
* Two namespaces, matching the backend: towns and localities share
* /schools/[place]; authorities take /schools/authority/[la]. 67 town names
* collide with an authority name and neither set contains the other, so one
* namespace would publish near-duplicate pages.
*/
import type { School } from '@/lib/types';
export interface PlaceSummary {
kind: string;
slug: string;
name: string;
count: number;
}
export interface PlaceDetail {
place: PlaceSummary & { parent_authority: string | null };
schools: School[];
averages: {
rwm_expected_pct: number | null;
attainment_8_score: number | null;
};
}
export function placeUrl(kind: string, slug: string, phase?: string): string {
const base =
kind === 'authority' ? ` /schools/authority/${slug}`
: kind === 'outcode' ? ` /schools/near/${slug}`
: ` /schools/${slug}`;
return phase ? ` ${base}/${phase}` : base;
}
const API = process.env.FASTAPI_URL || process.env.NEXT_PUBLIC_API_URL
|| 'http://localhost:8000/api';
export async function fetchPlaces(): Promise<PlaceSummary[]> {
const res = await fetch(` ${API}/places`, { next: { revalidate: 604800 } });
if (!res.ok) return [];
return (await res.json()).places ?? [];
}
export async function fetchPlace(
kind: string, slug: string, phase?: string,
): Promise<PlaceDetail | null> {
const q = phase ? ` ?phase=${encodeURIComponent(phase)}` : '';
const res = await fetch(` ${API}/places/${kind}/${slug}${q}`,
{ next: { revalidate: 604800 } });
if (!res.ok) return null;
return res.json();
}
` ``
- [ ] **Step 4: Write the component**
Create ` nextjs-app/components/places/PlaceView.tsx`:
` ``tsx
/**
* One place page, shared by all four families.
*
* They differ in what fills the registry, not in what the page shows, so a
* second component would be a second place to forget the same change.
*
* The local-versus-England comparison is the reason this page is not a list:
* it is the one number a parent cannot get by reading the schools one by one,
* and it is what keeps the page from reading as a name dropped into a
* template.
*/
import Link from 'next/link';
import type { PlaceDetail, PlaceSummary } from '@/lib/places';
import { placeUrl } from '@/lib/places';
import { schoolUrl } from '@/lib/utils';
import styles from './PlaceView.module.css';
interface Props {
detail: PlaceDetail;
phase?: 'primary' | 'secondary';
englandAverage: number | null;
/** Nearby places, so the page links onward instead of dead-ending. */
neighbours: PlaceSummary[];
}
// Ofsted grades in the order they are reported.
const OFSTED_LABELS: Array<[number, string]> = [
[1, 'Outstanding'], [2, 'Good'],
[3, 'Requires improvement'], [4, 'Inadequate'],
];
const OUTSTANDING = 1;
export function PlaceView({ detail, phase, englandAverage, neighbours }: Props) {
const { place, schools, averages } = detail;
const metric = phase === 'secondary' ? 'attainment_8_score' : 'rwm_expected_pct';
const local = averages[metric];
const outstanding = schools.filter((s) => s.ofsted_grade === OUTSTANDING).length;
const phaseWord = phase === 'secondary' ? 'Secondary schools'
: phase === 'primary' ? 'Primary schools' : 'Schools';
return (
<div className={styles.page}>
<h1>{phaseWord} in {place.name}</h1>
<p className={styles.summary}>
{place.count} schools
{outstanding > 0 && <>, {outstanding} rated Outstanding</>}
{place.parent_authority && (
<> · <Link href={` /schools/authority/${place.parent_authority.toLowerCase().replace(/\s+/g, '-')}`}>
{place.parent_authority}
</Link></>
)}
</p>
{local != null && englandAverage != null && (
<p className={styles.compare} data-testid="local-vs-england">
{place.name} averages <strong>{Math.round(local)}</strong> against{' '}
<strong>{Math.round(englandAverage)}</strong> across England.
</p>
)}
<ul className={styles.ofsted} data-testid="ofsted-distribution">
{OFSTED_LABELS.map(([grade, label]) => {
const n = schools.filter((s) => s.ofsted_grade === grade).length;
return n === 0 ? null : (
<li key={grade}>{label}: <strong>{n}</strong></li>
);
})}
</ul>
<div className={styles.tableWrap}>
<table className={styles.table}>
<thead>
<tr><th>School</th><th>{phase === 'secondary' ? 'Attainment 8' : 'RWM expected'}</th></tr>
</thead>
<tbody>
{schools.map((s) => (
<tr key={s.urn}>
<td><Link href={schoolUrl(s.urn, s.school_name)}>{s.school_name}</Link></td>
<td className={styles.num}>
{s[metric] == null ? '—' : Math.round(Number(s[metric]))}
</td>
</tr>
))}
</tbody>
</table>
</div>
{neighbours.length > 0 && (
<nav className={styles.neighbours} aria-label="Nearby places">
<h2>Nearby</h2>
<ul>
{neighbours.map((n) => (
<li key={n.kind + n.slug}>
<Link href={placeUrl(n.kind, n.slug)}>{n.name}</Link>
</li>
))}
</ul>
</nav>
)}
</div>
);
}
` ``
Create ` nextjs-app/components/places/PlaceView.module.css` following the
conventions in ` components/RankingsView.module.css` — read that file and match
its token usage rather than introducing new colours. The table must sit in an
` overflow-x: auto` wrapper so the page body never scrolls sideways.
- [ ] **Step 5: Run the tests to verify they pass**
Run: ` cd nextjs-app && npm test -- __tests__ /components/PlaceView.test.tsx`
Expected: PASS (6 tests).
- [ ] **Step 6: Commit**
` ``bash
git add nextjs-app/lib/places.ts nextjs-app/components/places/ \
nextjs-app/__tests__/components/PlaceView.test.tsx
git commit -m "feat(places): place page client and view component"
` ``
---
### Task 6: The routes
**Files:**
- Create: ` nextjs-app/app/schools/[place]/page.tsx`
- Create: ` nextjs-app/app/schools/[place]/[phase]/page.tsx`
- Create: ` nextjs-app/app/schools/authority/[la]/page.tsx`
- Create: ` nextjs-app/app/schools/near/[outcode]/page.tsx`
- Test: ` nextjs-app/__tests__/app/placeMetadata.test.ts`
**Interfaces:**
- Consumes: ` fetchPlace`, ` placeUrl`, ` PlaceView` (Task 5), ` absoluteUrl`
from ` @/lib/site `, ` fetchNationalAverages` from ` @/lib/api `.
- Produces: the four route families. Nothing later depends on them.
**Route resolution order matters.** ` app/schools/authority/[la]` and
` app/schools/near/[outcode]` are static segments and Next matches them before
the dynamic ` app/schools/[place]`, so an authority is never captured by the
place route. Do not rename ` authority` or ` near` to a bracketed segment.
- [ ] **Step 1: Write the failing metadata tests**
Create ` nextjs-app/__tests__/app/placeMetadata.test.ts`:
` ``typescript
import { generateMetadata as placeMeta } from '@/app/schools/[place]/page';
jest.mock('@/lib/places', () => ({
...jest.requireActual('@/lib/places'),
fetchPlace: jest.fn(async (kind: string, slug: string) =>
slug === 'atlantis' ? null : ({
place: { kind, slug, name: 'Brentwood', count: 29,
parent_authority: 'Essex' },
schools: [], averages: { rwm_expected_pct: 63, attainment_8_score: null },
})),
}));
describe('place page metadata', () => {
it('titles the page the way the place is searched', async () => {
const m = await placeMeta({ params: Promise.resolve({ place: 'brentwood' }) });
expect(m.title).toMatch(/schools in brentwood/i);
});
it('canonicalises to its own path on the www host', async () => {
const m = await placeMeta({ params: Promise.resolve({ place: 'brentwood' }) });
expect(m.alternates?.canonical)
.toBe('https://www.schoolcompare.co.uk/schools/brentwood');
});
it('an unknown place gets a not-found title rather than inventing one', async () => {
const m = await placeMeta({ params: Promise.resolve({ place: 'atlantis' }) });
expect(m.title).toMatch(/not found/i);
});
});
` ``
- [ ] **Step 2: Run the tests to verify they fail**
Run: ` cd nextjs-app && npm test -- __tests__ /app/placeMetadata.test.ts`
Expected: FAIL — module not found.
- [ ] **Step 3: Write the place route**
Create ` nextjs-app/app/schools/[place]/page.tsx`:
` ``tsx
/**
* Town and locality pages.
*
* A place below the five-school threshold is not in the registry, so
* fetchPlace returns null and the request 301s to the parent authority
* rather than rendering a page with nothing to say.
*/
import { notFound, redirect } from 'next/navigation';
import type { Metadata } from 'next';
import { fetchPlace, fetchPlaces } from '@/lib/places';
import { fetchNationalAverages } from '@/lib/api';
import { PlaceView } from '@/components/places/PlaceView';
import { absoluteUrl } from '@/lib/site';
interface Props { params: Promise<{ place: string }> }
// ISR: place aggregates change only when the pipeline runs.
export const revalidate = 604800;
export const dynamicParams = true;
export async function generateStaticParams(): Promise<Array<{ place: string }>> {
// Off by default: ~2,000 place routes cannot be built in CI on every deploy.
// Matches the PRERENDER_SCHOOLS gate on the school route.
if (process.env.PRERENDER_PLACES !== '1') return [];
2026-08-21 18:17:57 +01:00
try {
return (await fetchPlaces())
.filter((p) => p.kind === 'town' || p.kind === 'locality')
.map((p) => ({ place: p.slug }));
} catch (error) {
console.warn('generateStaticParams: API unreachable, falling back to on-demand ISR.', error);
return [];
}
2026-08-21 17:36:12 +01:00
}
async function resolve(slug: string) {
return (await fetchPlace('town', slug)) ?? (await fetchPlace('locality', slug));
}
/** Other towns in the same authority — the cheapest honest definition of
* "nearby", and enough to stop each place page being a dead end. */
async function neighboursOf(detail: { place: { slug: string; parent_authority: string | null } }) {
if (!detail.place.parent_authority) return [];
const all = await fetchPlaces();
return all
.filter((p) => p.kind === 'town' && p.slug !== detail.place.slug)
.slice(0, 12);
}
export async function generateMetadata({ params }: Props): Promise<Metadata> {
const { place: slug } = await params;
const detail = await resolve(slug);
if (!detail) return { title: 'Place Not Found' };
const { name, count } = detail.place;
return {
title: ` Schools in ${name} — Compare ${count} Schools | schoolcompare`,
description:
` Every school in ${name} ranked by SATs and GCSE results, with Ofsted grades, `
+ ` the local average against England, and how close you had to live to get a place.`,
alternates: { canonical: absoluteUrl(` /schools/${slug}`) },
};
}
export default async function PlacePage({ params }: Props) {
const { place: slug } = await params;
const detail = await resolve(slug);
if (!detail) notFound();
// Global constraint: no page without a local average. A place with too few
// schools carrying results has nothing to say that a list does not, so it
// defers to its authority rather than publishing a thin page.
if (detail.averages.rwm_expected_pct == null
&& detail.averages.attainment_8_score == null) {
if (detail.place.parent_authority) {
redirect(` /schools/authority/${detail.place.parent_authority
.toLowerCase().replace(/\s+/g, '-')}`);
}
notFound();
}
const national = await fetchNationalAverages().catch(() => null);
// NationalAverages is nested by phase — { primary: {...}, secondary: {...} }
// — not flat. Reading it flat silently yields undefined and the page renders
// with no comparison, which is the one thing that makes it not a list.
return (
<PlaceView
detail={detail}
englandAverage={national?.primary?.rwm_expected_pct ?? null}
neighbours={await neighboursOf(detail)}
/>
);
}
` ``
- [ ] **Step 4: Write the phase, authority and outcode routes**
Create ` nextjs-app/app/schools/[place]/[phase]/page.tsx`. It is the same
shape, with two differences: it validates the phase segment, and it passes
that phase to both ` fetchPlace` and ` PlaceView`.
` ``tsx
import { notFound } from 'next/navigation';
import type { Metadata } from 'next';
import { fetchPlace } from '@/lib/places';
import { fetchNationalAverages } from '@/lib/api';
import { PlaceView } from '@/components/places/PlaceView';
import { absoluteUrl } from '@/lib/site';
interface Props { params: Promise<{ place: string; phase: string }> }
export const revalidate = 604800;
export const dynamicParams = true;
const PHASES = ['primary', 'secondary'] as const;
type Phase = (typeof PHASES)[number];
const isPhase = (v: string): v is Phase => (PHASES as readonly string[]).includes(v);
async function resolve(slug: string, phase: Phase) {
return (await fetchPlace('town', slug, phase))
?? (await fetchPlace('locality', slug, phase));
}
export async function generateMetadata({ params }: Props): Promise<Metadata> {
const { place: slug, phase } = await params;
if (!isPhase(phase)) return { title: 'Place Not Found' };
const detail = await resolve(slug, phase);
// A place with no schools of this phase has no page — the per-phase
// threshold, not an error.
if (!detail || detail.schools.length === 0) return { title: 'Place Not Found' };
const word = phase === 'secondary' ? 'Secondary' : 'Primary';
const { name } = detail.place;
return {
title: ` ${word} Schools in ${name} — Ranked | schoolcompare`,
description:
` Every ${phase} school in ${name} ranked by results, with Ofsted grades and `
+ ` the local average against England.`,
alternates: { canonical: absoluteUrl(` /schools/${slug}/${phase}`) },
};
}
export default async function PlacePhasePage({ params }: Props) {
const { place: slug, phase } = await params;
if (!isPhase(phase)) notFound();
const detail = await resolve(slug, phase);
if (!detail || detail.schools.length === 0) notFound();
const national = await fetchNationalAverages().catch(() => null);
const englandAverage = phase === 'secondary'
? national?.secondary?.attainment_8_score ?? null
: national?.primary?.rwm_expected_pct ?? null;
return <PlaceView detail={detail} phase={phase}
englandAverage={englandAverage} neighbours={[]} />;
}
` ``
Create ` nextjs-app/app/schools/authority/[la]/page.tsx`:
` ``tsx
/**
* Local authority pages.
*
* A separate namespace from /schools/[place] because 67 town names collide
* with an authority name and neither set contains the other — Bedford the
* town holds 104 schools, Bedford the authority 86, because postal towns
* cross authority boundaries. The title says "Local Authority" so a reader
* landing on both knows which set each covers.
*/
import { notFound } from 'next/navigation';
import type { Metadata } from 'next';
import { fetchPlace, fetchPlaces } from '@/lib/places';
import { fetchNationalAverages } from '@/lib/api';
import { PlaceView } from '@/components/places/PlaceView';
import { absoluteUrl } from '@/lib/site';
interface Props { params: Promise<{ la: string }> }
export const revalidate = 604800;
export const dynamicParams = true;
export async function generateStaticParams(): Promise<Array<{ la: string }>> {
2026-08-21 18:17:57 +01:00
// Gated like every other prerender in this app. There are only ~154
// authorities, but "few enough to always build" still means the API must be
// reachable at build time, and in CI it is not — the build fails with
// ECONNREFUSED rather than degrading. The catch is the same fallback the
// school route uses.
if (process.env.PRERENDER_PLACES !== '1') return [];
try {
return (await fetchPlaces())
.filter((p) => p.kind === 'authority')
.map((p) => ({ la: p.slug }));
} catch (error) {
console.warn('generateStaticParams: API unreachable, falling back to on-demand ISR.', error);
return [];
}
2026-08-21 17:36:12 +01:00
}
export async function generateMetadata({ params }: Props): Promise<Metadata> {
const { la } = await params;
const detail = await fetchPlace('authority', la);
if (!detail) return { title: 'Place Not Found' };
const { name, count } = detail.place;
return {
title: ` Schools in ${name} — Local Authority | schoolcompare`,
description:
` All ${count} schools in the ${name} local authority, ranked by SATs and GCSE `
+ ` results, with Ofsted grades and the authority average against England.`,
alternates: { canonical: absoluteUrl(` /schools/authority/${la}`) },
};
}
export default async function AuthorityPage({ params }: Props) {
const { la } = await params;
const detail = await fetchPlace('authority', la);
if (!detail) notFound();
const national = await fetchNationalAverages().catch(() => null);
return (
<PlaceView
detail={detail}
englandAverage={national?.primary?.rwm_expected_pct ?? null}
neighbours={[]}
/>
);
}
` ``
Create ` nextjs-app/app/schools/near/[outcode]/page.tsx`:
` ``tsx
/**
* Postcode district pages.
*
* No phase variants: nobody searches "primary schools in SW11", so the
* variants would be pages without demand. These exist to catch
* "schools near <postcode>" and to give London districts a geographic page
* where the GIAS town field cannot.
*/
import { notFound } from 'next/navigation';
import type { Metadata } from 'next';
import { fetchPlace, fetchPlaces } from '@/lib/places';
import { fetchNationalAverages } from '@/lib/api';
import { PlaceView } from '@/components/places/PlaceView';
import { absoluteUrl } from '@/lib/site';
interface Props { params: Promise<{ outcode: string }> }
export const revalidate = 604800;
export const dynamicParams = true;
export async function generateStaticParams(): Promise<Array<{ outcode: string }>> {
// 1,760 of these; same CI budget argument as the town routes.
if (process.env.PRERENDER_PLACES !== '1') return [];
2026-08-21 18:17:57 +01:00
try {
return (await fetchPlaces())
.filter((p) => p.kind === 'outcode')
.map((p) => ({ outcode: p.slug }));
} catch (error) {
console.warn('generateStaticParams: API unreachable, falling back to on-demand ISR.', error);
return [];
}
2026-08-21 17:36:12 +01:00
}
export async function generateMetadata({ params }: Props): Promise<Metadata> {
const { outcode } = await params;
const detail = await fetchPlace('outcode', outcode);
if (!detail) return { title: 'Place Not Found' };
const { name, count } = detail.place;
return {
title: ` Schools near ${name} | schoolcompare`,
description:
` ${count} schools in the ${name} postcode district, ranked by results, with `
+ ` Ofsted grades and how close you had to live to get a place.`,
alternates: { canonical: absoluteUrl(` /schools/near/${outcode}`) },
};
}
export default async function OutcodePage({ params }: Props) {
const { outcode } = await params;
const detail = await fetchPlace('outcode', outcode);
if (!detail) notFound();
const national = await fetchNationalAverages().catch(() => null);
return (
<PlaceView
detail={detail}
englandAverage={national?.primary?.rwm_expected_pct ?? null}
neighbours={[]}
/>
);
}
` ``
- [ ] **Step 5: Run the tests, typecheck and build**
Run:
` ``bash
cd nextjs-app && npm test && npx tsc --noEmit && npx next build
` ``
Expected: Jest green; typecheck clean; the build lists ` /schools/[place]`,
` /schools/[place]/[phase]`, ` /schools/authority/[la]` and
` /schools/near/[outcode]` as dynamic routes.
- [ ] **Step 6: Commit**
` ``bash
git add nextjs-app/app/schools nextjs-app/__tests__/app/placeMetadata.test.ts
git commit -m "feat(places): town, locality, authority and outcode routes"
` ``
---
### Task 7: Structured data and the e2e gate
**Files:**
- Modify: ` nextjs-app/components/places/PlaceView.tsx` (JSON-LD)
- Modify: ` e2e/tests/journeys.spec.ts`
**Interfaces:**
- Consumes: everything above.
- Produces: nothing.
- [ ] **Step 1: Add ItemList and BreadcrumbList to the place page**
In ` PlaceView.tsx`, build the structured data above the returned markup and
render it as the first child:
` ``tsx
const jsonLd = {
'@context': 'https://schema.org',
'@graph': [
{
'@type': 'ItemList',
name: ` ${phaseWord} in ${place.name}`,
numberOfItems: schools.length,
itemListElement: schools.slice(0, 20).map((s, i) => ({
'@type': 'ListItem',
position: i + 1,
url: ` https://www.schoolcompare.co.uk${schoolUrl(s.urn, s.school_name)}`,
name: s.school_name,
})),
},
{
'@type': 'BreadcrumbList',
itemListElement: [
{ '@type': 'ListItem', position: 1, name: 'Schools',
item: 'https://www.schoolcompare.co.uk/' },
{ '@type': 'ListItem', position: 2, name: place.name },
],
},
],
};
` ``
` ``tsx
<script
type="application/ld+json"
dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd) }}
/>
` ``
- [ ] **Step 2: Add the e2e journeys**
Append to ` e2e/tests/journeys.spec.ts`:
` ``typescript
/*
* The location layer (spec 2026-08-21, W2).
*
* Location intent sat at position 49.5 with one click across the whole 16-month
* baseline — the site published no page about a place. These assert the four
* families render, stay in their own namespaces, and reach the sitemap.
*/
async function firstPlaceOfKind(page: Page, kind: string) {
const res = await page.request.get('/api/places');
expect(res.ok()).toBeTruthy();
const { places } = await res.json();
const hit = places.find((p: { kind: string }) => p.kind === kind);
expect(hit, ` no ${kind} in the registry`).toBeTruthy();
return hit;
}
for (const [kind, prefix] of [
['town', '/schools/'],
['authority', '/schools/authority/'],
['outcode', '/schools/near/'],
] as const) {
test(` a ${kind} page renders with its school count`, async ({ page }) => {
const place = await firstPlaceOfKind(page, kind);
await page.goto(` ${prefix}${place.slug}`);
await expect(page.locator('h1')).toContainText(place.name, { ignoreCase: true });
await expect(page.locator('a[href^="/school/"]').first()).toBeVisible();
});
}
test('a town and an authority sharing a name are different pages', async ({ page }) => {
// 67 real collisions, and the authority is the larger set in only 43 — so
// one namespace would have published near-duplicates.
const res = await page.request.get('/api/places');
const { places } = await res.json();
const towns = new Map(places.filter((p: never) => (p as { kind: string }).kind === 'town')
.map((p: never) => [(p as { slug: string }).slug, p]));
const clash = places.find((p: { kind: string; slug: string }) =>
p.kind === 'authority' && towns.has(p.slug));
test.skip(!clash, 'no town/authority name collision in this environment');
const townRes = await page.request.get(` /api/places/town/${clash.slug}`);
const laRes = await page.request.get(` /api/places/authority/${clash.slug}`);
expect(townRes.ok() && laRes.ok()).toBeTruthy();
const townUrns = (await townRes.json()).schools.map((s: { urn: number }) => s.urn);
const laUrns = (await laRes.json()).schools.map((s: { urn: number }) => s.urn);
expect(townUrns.sort()).not.toEqual(laUrns.sort());
});
test('a place below the threshold has no page', async ({ page }) => {
// Crosby holds one school; publishing it would be a page with nothing to say.
const res = await page.request.get('/api/places/town/crosby');
expect(res.status()).toBe(404);
});
test('place pages declare a canonical and reach the sitemap', async ({ page }) => {
const place = await firstPlaceOfKind(page, 'town');
await page.goto(` /schools/${place.slug}`);
const canonical = await page.locator('link[rel="canonical"]').first()
.getAttribute('href');
expect(canonical).toBe(` https://www.schoolcompare.co.uk/schools/${place.slug}`);
const xml = await (await page.request.get('/sitemaps/places-1.xml')).text();
expect(xml).toContain(` /schools/${place.slug}`);
});
test('the place page ships ItemList structured data that parses', async ({ page }) => {
const place = await firstPlaceOfKind(page, 'town');
await page.goto(` /schools/${place.slug}`);
const raw = await page.locator('script[type="application/ld+json"]').first()
.textContent();
const parsed = JSON.parse(raw!);
const types = (parsed['@graph'] ?? []).map((n: { '@type': string }) => n['@type']);
expect(types).toContain('ItemList');
expect(types).toContain('BreadcrumbList');
});
` ``
- [ ] **Step 3: Verify everything**
Run:
` ``bash
cd nextjs-app && npm test && npx tsc --noEmit && npx next build && cd ..
uv run --quiet --with-requirements requirements.txt --with pytest \
--with "httpx==0.27.0" python -m pytest backend/tests -q
cd e2e && npx playwright test --list
` ``
Expected: all green; the e2e listing gains the new journeys.
- [ ] **Step 4: Commit**
` ``bash
git add nextjs-app/components/places/PlaceView.tsx e2e/tests/journeys.spec.ts
git commit -m "feat(places): ItemList and BreadcrumbList, and the e2e gate"
` ``
---
## After the plan
1. Open a PR. Do not merge until the staging pipeline has run — the registry
is built from the marts, and a stale staging database publishes a different
set of places than the tests expect.
2. After deploy, ` POST /api/admin/regenerate-sitemap` to rebuild both the
registry and the sitemaps.
3. Submit ` /sitemap.xml` in Search Console. The places and outcodes children
report separately, which is how the layer's indexation gets measured.
4. **The stop condition, agreed in the spec:** if indexation of the places
sitemap stalls below roughly half, stop and rethink rather than adding more
pages.
## Deliberately not in this plan
**Catchment-area estimation.** ` fact_admissions` carries the distances and it
is a strong driver for this cluster, but it is a modelling problem with real
accuracy risk — a wrong catchment boundary is worse than none — and it needs
its own design.
**A map.** The spec lists one per place page. ` components/` already has the
Leaflet setup the school pages use, so it is mechanical rather than hard — but
it carries no SEO weight (Google does not read a canvas), the page is complete
without it, and adding 4,000 Leaflet instances has a real performance cost
worth measuring on its own. Ship the layer, then add the map deliberately.
**An FAQ block and ` FAQPage` markup.** The spec lists it. It needs written
answers per place kind, which is a copy task rather than a build task, and
bundling it would hold up 4,000 pages behind copywriting. Worth its own small
PR once the layer is indexing.