Files
school_compare/docs/superpowers/plans/2026-08-21-w2-location-layer.md
T
TudorandClaude Opus 5 555d3f0a7d docs(seo): implementation plan for the W2 location layer
Seven tasks: the place registry, London localities and outcodes, the places
API, per-family sitemaps, the shared place view, the four route families, and
structured data plus the e2e gate.

Two things the plan corrects against the spec. The backend image does not
contain pipeline/, so the curated locality list cannot live only in a dbt
seed — it follows the gias_codes.py precedent instead, canonical in backend
with the seed as a mirror. And NationalAverages is nested by phase rather than
flat, which the first draft read wrongly and would have rendered every page
without its England comparison.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015mWQnpye9F299NVRCCSRvj
2026-08-21 18:09:35 +01:00

64 KiB
Raw Blame History

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:
    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:

    @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:

"""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])


def _town(n: int, town: str, la: str, **kw) -> list[dict]:
    return [
        {"urn": 100000 + i, "school_name": f"{town} School {i}",
         "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")
            + _town(MIN_SCHOOLS, "Bedford", "Central Bedfordshire"))
    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:

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:

"""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:

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
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:

    # 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:

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:

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:

"""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:

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:

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:

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:

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
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:

"""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:

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:

# 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:

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:

@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:

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:

    global _sitemaps, _place_registry
    _place_registry = None
    _sitemaps = build_sitemaps()
  • Step 6: Run the whole backend suite

Run:

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
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:

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:

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:

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:

    # 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:

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:

const CHILD = /^(static|schools-\d+|places-\d+|outcodes-\d+)\.xml$/;
  • Step 6: Run the whole backend suite and commit
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:

    // 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:

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:

/**
 * 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:

/**
 * 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
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:

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:

/**
 * 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 [];
  const { fetchPlaces } = await import('@/lib/places');
  return (await fetchPlaces())
    .filter((p) => p.kind === 'town' || p.kind === 'locality')
    .map((p) => ({ place: p.slug }));
}

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.

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:

/**
 * 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 }>> {
  // Only ~154 authorities, so these are cheap enough to always prebuild —
  // unlike towns, which are gated behind PRERENDER_PLACES.
  return (await fetchPlaces())
    .filter((p) => p.kind === 'authority')
    .map((p) => ({ la: p.slug }));
}

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:

/**
 * 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 [];
  return (await fetchPlaces())
    .filter((p) => p.kind === 'outcode')
    .map((p) => ({ outcode: p.slug }));
}

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:

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
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:

  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 },
        ],
      },
    ],
  };
      <script
        type="application/ld+json"
        dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd) }}
      />
  • Step 2: Add the e2e journeys

Append to e2e/tests/journeys.spec.ts:

/*
 * 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:

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
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.