/** * 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> { 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 { 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 ( <>