Files
school_compare/nextjs-app/app/(frontend)/school/[slug]/page.tsx
T
TudorandClaude Opus 5 7f5f0fb676
PR Checks / Frontend Typecheck + Tests (pull_request) Successful in 1m14s
PR Checks / Backend Smoke (pull_request) Successful in 9s
PR Checks / Build Backend (no push) (pull_request) Successful in 22s
PR Checks / Build Frontend (no push) (pull_request) Successful in 1m24s
PR Checks / Build Pipeline (no push) (pull_request) Successful in 11s
PR Checks / AI Code Review (Claude) (pull_request) Successful in 1m18s
feat(seo): link school pages into the location layer
W2 shipped ~5,000 place pages and nothing linked into them. The
location layer pointed down at school pages; school pages pointed
nowhere on the site. Their only anchor was the school's own website, so
the ~27k pages carrying most of the site's inbound authority passed it
straight off-site, and the new corpus was reachable mainly through the
sitemap.

Three things close the loop.

A reverse index over the place registry, places_for_urn, answers which
published places contain a school. Derived from the registry rather
than stored beside it, so the two cannot disagree about which places
exist: a place below the publish threshold is absent from the registry
and therefore never offered as a link. A test asserts that invariant
across every place in a built registry.

GET /api/schools/{urn} gains a `places` array carrying the name, count
and canonical path for each. It rides on the request the page already
makes, so the school page costs no extra round trip. The frontend types
it optional and defaults it to empty, because the two images deploy
separately and a frontend ahead of the API must render without it.

The page gains a "More schools near here" module and a BreadcrumbList.
The module orders narrowest first, because a reader on a school page
wants its town before its county, while the API orders widest first for
the trail. Anchors state their destination's size — "37 schools in
Brentwood" — which is worth more to a reader and a crawler than "see
more". With no published places it renders nothing rather than an empty
heading.

The trail is rooted at the homepage, not /schools. There is no /schools
index page; the location layer lives only at /schools/[place],
/schools/authority/[la] and /schools/near/[outcode]. Rooting it at the
bare path would have opened every breadcrumb with a link to a 404.
Outcodes are omitted from the trail: "schools near CM15" is a real
query and a useful link, but nobody navigates Essex to CM15 to a
school, and a breadcrumb claiming that describes a hierarchy the site
does not have.

School pages also now declare the School type rather than
EducationalOrganization, the parent type that covers universities and
nurseries alike.

The e2e journey asserts the round trip in both directions, following a
place page's own first school so the pair is genuinely related rather
than hardcoded. A one-way link is what already existed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DXnXQKnPpZBBP61fBQiFkq
2026-09-14 20:45:50 +01:00

295 lines
11 KiB
TypeScript

/**
* Individual School Page (SSR)
* Dynamic route for school details with full SEO optimization
* URL format: /school/138267-school-name-here
*/
import { fetchSchoolDetails, fetchSchools, fetchNationalAverages } from '@/lib/api';
import { notFound, redirect } from 'next/navigation';
import { SchoolDetailShell } from '@/components/school/SchoolDetailShell';
import { NearbyPlaces } from '@/components/school/NearbyPlaces';
import { schoolBreadcrumbJsonLd, type SchoolPlace } from '@/lib/jsonld';
import { PrimarySchoolSections } from '@/components/school/PrimarySchoolSections';
import { SecondarySchoolSections } from '@/components/school/SecondarySchoolSections';
import {
computeSchoolFlags, buildNavItems,
computeSecondaryFlags, buildSecondaryNavItems,
} from '@/lib/schoolSections';
import { parseSchoolSlug, schoolUrl } from '@/lib/utils';
import type { NationalAverages } from '@/lib/types';
import { absoluteUrl } from '@/lib/site';
import type { Metadata } from 'next';
/**
* Enumerate every school for static generation at build time.
*
* Set PRERENDER_SCHOOLS=1 in the build environment to enable. When disabled
* (or when the API can't be reached), we return an empty list and the route
* falls back to ISR on first request — `dynamicParams = true` covers it.
*/
export async function generateStaticParams(): Promise<Array<{ slug: string }>> {
if (process.env.PRERENDER_SCHOOLS !== '1') return [];
const params: Array<{ slug: string }> = [];
const PAGE_SIZE = 500;
let page = 1;
let totalPages = 1;
try {
do {
const res = await fetchSchools({ page, page_size: PAGE_SIZE });
for (const s of res.schools) {
const path = schoolUrl(s.urn, s.school_name);
const slug = path.replace('/school/', '');
params.push({ slug });
}
totalPages = res.total_pages || 1;
page += 1;
} while (page <= totalPages);
} catch (error) {
console.warn('generateStaticParams: API unreachable, falling back to on-demand ISR.', error);
return [];
}
console.log(`generateStaticParams: prebuilding ${params.length} school pages.`);
return params;
}
interface SchoolPageProps {
params: Promise<{ slug: string }>;
}
export async function generateMetadata({ params }: SchoolPageProps): Promise<Metadata> {
const { slug } = await params;
const urn = parseSchoolSlug(slug);
if (!urn || urn < 100000 || urn > 999999) {
return {
title: 'School Not Found',
};
}
try {
const data = await fetchSchoolDetails(urn);
const { school_info } = data;
const canonicalPath = schoolUrl(urn, school_info.school_name);
const phaseStr = (school_info.phase ?? '').toLowerCase();
const isAllThrough = phaseStr === 'all-through';
const isSecondary = !isAllThrough && (
phaseStr.includes('secondary')
|| (data.yearly_data ?? []).some((d: any) => d.attainment_8_score != null)
);
const la = school_info.local_authority ? ` in ${school_info.local_authority}` : '';
const title = `${school_info.school_name} | ${school_info.local_authority || 'England'}`;
const description = isAllThrough
? `View KS2 SATs and GCSE results for ${school_info.school_name}${la}. All-through school covering primary and secondary education.`
: isSecondary
? `View GCSE results, Attainment 8, Progress 8 and school statistics for ${school_info.school_name}${la}.`
: `View KS2 performance data, results, and statistics for ${school_info.school_name}${la}. Compare reading, writing, and maths results.`;
return {
title,
description,
keywords: isAllThrough
? `${school_info.school_name}, KS2 results, GCSE results, all-through school, ${school_info.local_authority}, SATs, Attainment 8`
: isSecondary
? `${school_info.school_name}, GCSE results, secondary school, ${school_info.local_authority}, Attainment 8, Progress 8`
: `${school_info.school_name}, KS2 results, primary school, ${school_info.local_authority}, school performance, SATs results`,
openGraph: {
title,
description,
type: 'website',
url: absoluteUrl(canonicalPath),
siteName: 'schoolcompare',
},
twitter: {
card: 'summary',
title,
description,
},
alternates: {
canonical: absoluteUrl(canonicalPath),
},
};
} catch {
return {
title: 'School Not Found',
};
}
}
// ISR: regenerate at most once a week per slug. School data updates annually,
// so a 7-day cache is plenty and gives sub-100ms TTFB on cache hits.
export const revalidate = 604800;
export const dynamicParams = true;
export default async function SchoolPage({ params }: SchoolPageProps) {
const { slug } = await params;
const urn = parseSchoolSlug(slug);
// Validate URN format
if (!urn || urn < 100000 || urn > 999999) {
notFound();
}
// Fetch school data. National averages feed the England-comparison deltas
// across most sections; fetching them here rather than in a client effect
// keeps those sections server-renderable and puts the deltas in the initial
// HTML. They are supplementary, so they degrade to null rather than 404ing
// the page.
let data;
let nationalAvg: NationalAverages | null = null;
try {
[data, nationalAvg] = await Promise.all([
fetchSchoolDetails(urn),
fetchNationalAverages().catch(() => null),
]);
} catch (error) {
console.error(`Failed to fetch school ${urn}:`, error);
notFound();
}
const { school_info, yearly_data, absence_data, ofsted, census, admissions, admissions_history, admission_distance, deprivation, finance, destinations } = data;
// Absent on an older API build; the module and the trail both degrade to
// nothing rather than throwing, which is how this shipped without a
// lockstep deploy of the two images.
const places: SchoolPlace[] = data.places ?? [];
// Redirect bare URN to canonical slug URL
const canonicalSlug = schoolUrl(urn, school_info.school_name).replace('/school/', '');
if (slug !== canonicalSlug) {
redirect(`/school/${canonicalSlug}`);
}
const phaseStr = (school_info.phase ?? '').toLowerCase();
const isAllThrough = phaseStr === 'all-through';
// All-through schools go to PrimarySchoolSections (renders both KS2 + KS4).
// SecondarySchoolSections is KS4-only, so all-through schools would lose SATs data.
const isSecondary = !isAllThrough && (
phaseStr.includes('secondary')
|| yearly_data.some((d: any) => d.attainment_8_score != null)
);
// Section list is computed on the server so the client shell never needs to
// derive it -- and so it can never disagree with what the sections render.
const sectionInput = {
schoolInfo: school_info, yearlyData: yearly_data,
absenceData: absence_data, census: census ?? null,
deprivation: deprivation ?? null, finance: finance ?? null,
destinations: destinations ?? null,
};
const primaryFlags = computeSchoolFlags(sectionInput);
const secondaryFlags = computeSecondaryFlags(sectionInput);
const navInput = {
ofsted: ofsted ?? null,
admissions: admissions ?? null,
admissionDistance: admission_distance ?? null,
hasLocation: school_info.latitude != null && school_info.longitude != null,
yearlyDataLength: yearly_data.length,
};
const primaryNavItems = buildNavItems(primaryFlags, navInput);
const secondaryNavItems = buildSecondaryNavItems(secondaryFlags, navInput);
/*
* `School`, not `EducationalOrganization`.
*
* Both are valid, but EducationalOrganization is the parent type covering
* universities, training providers and nurseries alike. School is the
* specific one, and a type that says what the page is about is the whole
* point of declaring it. Google's own guidance treats the narrower type as
* the correct choice where it applies.
*/
const structuredData = {
'@context': 'https://schema.org',
'@graph': [{
'@type': 'School',
name: school_info.school_name,
identifier: school_info.urn.toString(),
...(school_info.address && {
address: {
'@type': 'PostalAddress',
streetAddress: school_info.address,
addressLocality: school_info.local_authority || undefined,
postalCode: school_info.postcode || undefined,
addressCountry: 'GB',
},
}),
...(school_info.latitude && school_info.longitude && {
geo: {
'@type': 'GeoCoordinates',
latitude: school_info.latitude,
longitude: school_info.longitude,
},
}),
...(school_info.school_type && {
additionalType: school_info.school_type,
}),
},
// The trail the page sits at the end of. School pages carried no
// breadcrumb at all, while every place page already emitted one.
schoolBreadcrumbJsonLd({
name: school_info.school_name,
url: `/school/${slug}`,
places,
}),
],
};
return (
<>
<script
type="application/ld+json"
dangerouslySetInnerHTML={{ __html: JSON.stringify(structuredData) }}
/>
{isSecondary ? (
<SchoolDetailShell
schoolInfo={school_info}
yearlyData={yearly_data}
census={census ?? null}
navItems={secondaryNavItems}
>
<SecondarySchoolSections
schoolInfo={school_info}
yearlyData={yearly_data}
absenceData={absence_data}
ofsted={ofsted ?? null}
census={census ?? null}
admissions={admissions ?? null}
admissionsHistory={admissions_history ?? []}
admissionDistance={admission_distance}
deprivation={deprivation ?? null}
finance={finance ?? null}
nationalAvg={nationalAvg}
destinations={destinations ?? null}
flags={secondaryFlags}
/>
</SchoolDetailShell>
) : (
<SchoolDetailShell
schoolInfo={school_info}
yearlyData={yearly_data}
census={census ?? null}
navItems={primaryNavItems}
>
<PrimarySchoolSections
schoolInfo={school_info}
yearlyData={yearly_data}
absenceData={absence_data}
ofsted={ofsted ?? null}
census={census ?? null}
admissions={admissions ?? null}
admissionsHistory={admissions_history ?? []}
admissionDistance={admission_distance ?? null}
deprivation={deprivation ?? null}
finance={finance ?? null}
nationalAvg={nationalAvg}
flags={primaryFlags}
/>
</SchoolDetailShell>
)}
<NearbyPlaces places={places} />
</>
);
}