Files
school_compare/nextjs-app/app/(frontend)/school/[slug]/page.tsx
T
TudorandClaude Opus 5.5 5f9caad7f4 fix: count pupils as the whole school, not the GCSE year group
fact_performance's total_pupils is the cohort a year's results were
measured on. For a secondary that is Year 11 alone, and the list API sent
it as the card's "pupils": Burntwood showed 245 in search and 1,462 on its
page. The list and place payloads now carry the register's whole-school
count, and nothing when the register has none. Map popups and the compare
basket read the same field.

The header and the wellbeing section fell back to the same results figure
when the census had no record. They now fall back to the register count.
The shell took yearlyData only for that fallback, so the prop is gone and
the results array no longer ships to the client for the chrome.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 22:51:31 +01:00

299 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 { APIFetchError, 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 { shouldRenderNearby } from '@/components/school/NearbySchoolsSection';
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) {
if (error instanceof APIFetchError && error.status === 404) notFound();
throw error;
}
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 ?? [];
// Absent on an older API build, exactly like `places` above.
const nearbySchools = data.nearby_schools ?? [];
// 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,
hasNearbySchools: shouldRenderNearby(nearbySchools),
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}
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}
nearbySchools={nearbySchools}
flags={secondaryFlags}
/>
</SchoolDetailShell>
) : (
<SchoolDetailShell
schoolInfo={school_info}
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}
nearbySchools={nearbySchools}
flags={primaryFlags}
/>
</SchoolDetailShell>
)}
<NearbyPlaces places={places} />
</>
);
}