2026-02-02 20:34:35 +00:00
|
|
|
/**
|
|
|
|
|
* Individual School Page (SSR)
|
|
|
|
|
* Dynamic route for school details with full SEO optimization
|
2026-03-29 12:41:28 +01:00
|
|
|
* URL format: /school/138267-school-name-here
|
2026-02-02 20:34:35 +00:00
|
|
|
*/
|
|
|
|
|
|
2026-08-01 21:11:08 +01:00
|
|
|
import { fetchSchoolDetails, fetchSchools, fetchNationalAverages } from '@/lib/api';
|
2026-03-29 12:41:28 +01:00
|
|
|
import { notFound, redirect } from 'next/navigation';
|
2026-08-02 21:39:21 +01:00
|
|
|
import { SchoolDetailShell } from '@/components/school/SchoolDetailShell';
|
|
|
|
|
import { PrimarySchoolSections } from '@/components/school/PrimarySchoolSections';
|
|
|
|
|
import { SecondarySchoolSections } from '@/components/school/SecondarySchoolSections';
|
|
|
|
|
import {
|
|
|
|
|
computeSchoolFlags, buildNavItems,
|
|
|
|
|
computeSecondaryFlags, buildSecondaryNavItems,
|
|
|
|
|
} from '@/lib/schoolSections';
|
2026-03-29 12:41:28 +01:00
|
|
|
import { parseSchoolSlug, schoolUrl } from '@/lib/utils';
|
2026-08-01 21:11:08 +01:00
|
|
|
import type { NationalAverages } from '@/lib/types';
|
2026-02-02 20:34:35 +00:00
|
|
|
import type { Metadata } from 'next';
|
|
|
|
|
|
2026-06-02 13:46:45 +01:00
|
|
|
/**
|
|
|
|
|
* 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;
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-02 20:34:35 +00:00
|
|
|
interface SchoolPageProps {
|
2026-03-29 12:41:28 +01:00
|
|
|
params: Promise<{ slug: string }>;
|
2026-02-02 20:34:35 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export async function generateMetadata({ params }: SchoolPageProps): Promise<Metadata> {
|
2026-03-29 12:41:28 +01:00
|
|
|
const { slug } = await params;
|
|
|
|
|
const urn = parseSchoolSlug(slug);
|
2026-02-02 20:34:35 +00:00
|
|
|
|
2026-03-29 12:41:28 +01:00
|
|
|
if (!urn || urn < 100000 || urn > 999999) {
|
2026-02-02 20:34:35 +00:00
|
|
|
return {
|
|
|
|
|
title: 'School Not Found',
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
const data = await fetchSchoolDetails(urn);
|
|
|
|
|
const { school_info } = data;
|
|
|
|
|
|
2026-03-29 12:41:28 +01:00
|
|
|
const canonicalPath = schoolUrl(urn, school_info.school_name);
|
2026-03-30 14:07:30 +01:00
|
|
|
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}` : '';
|
2026-02-02 20:34:35 +00:00
|
|
|
const title = `${school_info.school_name} | ${school_info.local_authority || 'England'}`;
|
2026-03-30 14:07:30 +01:00
|
|
|
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.`;
|
2026-02-02 20:34:35 +00:00
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
title,
|
|
|
|
|
description,
|
2026-03-30 14:07:30 +01:00
|
|
|
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`,
|
2026-02-02 20:34:35 +00:00
|
|
|
openGraph: {
|
|
|
|
|
title,
|
|
|
|
|
description,
|
|
|
|
|
type: 'website',
|
2026-03-29 12:41:28 +01:00
|
|
|
url: `https://schoolcompare.co.uk${canonicalPath}`,
|
2026-02-02 20:34:35 +00:00
|
|
|
siteName: 'SchoolCompare',
|
|
|
|
|
},
|
|
|
|
|
twitter: {
|
|
|
|
|
card: 'summary',
|
|
|
|
|
title,
|
|
|
|
|
description,
|
|
|
|
|
},
|
|
|
|
|
alternates: {
|
2026-03-29 12:41:28 +01:00
|
|
|
canonical: `https://schoolcompare.co.uk${canonicalPath}`,
|
2026-02-02 20:34:35 +00:00
|
|
|
},
|
|
|
|
|
};
|
|
|
|
|
} catch {
|
|
|
|
|
return {
|
|
|
|
|
title: 'School Not Found',
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-02 13:46:45 +01:00
|
|
|
// 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;
|
2026-02-02 20:34:35 +00:00
|
|
|
|
|
|
|
|
export default async function SchoolPage({ params }: SchoolPageProps) {
|
2026-03-29 12:41:28 +01:00
|
|
|
const { slug } = await params;
|
|
|
|
|
const urn = parseSchoolSlug(slug);
|
2026-02-02 20:34:35 +00:00
|
|
|
|
|
|
|
|
// Validate URN format
|
2026-03-29 12:41:28 +01:00
|
|
|
if (!urn || urn < 100000 || urn > 999999) {
|
2026-02-02 20:34:35 +00:00
|
|
|
notFound();
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-01 21:11:08 +01:00
|
|
|
// 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.
|
2026-02-02 20:34:35 +00:00
|
|
|
let data;
|
2026-08-01 21:11:08 +01:00
|
|
|
let nationalAvg: NationalAverages | null = null;
|
2026-02-02 20:34:35 +00:00
|
|
|
try {
|
2026-08-01 21:11:08 +01:00
|
|
|
[data, nationalAvg] = await Promise.all([
|
|
|
|
|
fetchSchoolDetails(urn),
|
|
|
|
|
fetchNationalAverages().catch(() => null),
|
|
|
|
|
]);
|
2026-02-02 20:34:35 +00:00
|
|
|
} catch (error) {
|
|
|
|
|
console.error(`Failed to fetch school ${urn}:`, error);
|
|
|
|
|
notFound();
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-24 09:23:08 +01:00
|
|
|
const { school_info, yearly_data, absence_data, ofsted, census, admissions, admissions_history, deprivation, finance } = data;
|
2026-02-02 20:34:35 +00:00
|
|
|
|
2026-03-29 12:41:28 +01:00
|
|
|
// Redirect bare URN to canonical slug URL
|
|
|
|
|
const canonicalSlug = schoolUrl(urn, school_info.school_name).replace('/school/', '');
|
|
|
|
|
if (slug !== canonicalSlug) {
|
|
|
|
|
redirect(`/school/${canonicalSlug}`);
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-30 14:07:30 +01:00
|
|
|
const phaseStr = (school_info.phase ?? '').toLowerCase();
|
|
|
|
|
const isAllThrough = phaseStr === 'all-through';
|
2026-08-02 21:39:21 +01:00
|
|
|
// All-through schools go to PrimarySchoolSections (renders both KS2 + KS4).
|
|
|
|
|
// SecondarySchoolSections is KS4-only, so all-through schools would lose SATs data.
|
2026-03-30 14:07:30 +01:00
|
|
|
const isSecondary = !isAllThrough && (
|
|
|
|
|
phaseStr.includes('secondary')
|
|
|
|
|
|| yearly_data.some((d: any) => d.attainment_8_score != null)
|
|
|
|
|
);
|
2026-03-28 22:36:00 +00:00
|
|
|
|
2026-08-02 21:39:21 +01:00
|
|
|
// 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,
|
|
|
|
|
};
|
|
|
|
|
const primaryFlags = computeSchoolFlags(sectionInput);
|
|
|
|
|
const secondaryFlags = computeSecondaryFlags(sectionInput);
|
|
|
|
|
const navInput = {
|
|
|
|
|
ofsted: ofsted ?? null,
|
|
|
|
|
admissions: admissions ?? null,
|
|
|
|
|
yearlyDataLength: yearly_data.length,
|
|
|
|
|
};
|
|
|
|
|
const primaryNavItems = buildNavItems(primaryFlags, navInput);
|
|
|
|
|
const secondaryNavItems = buildSecondaryNavItems(secondaryFlags, navInput);
|
|
|
|
|
|
2026-02-02 20:34:35 +00:00
|
|
|
// Generate JSON-LD structured data for SEO
|
|
|
|
|
const structuredData = {
|
|
|
|
|
'@context': 'https://schema.org',
|
|
|
|
|
'@type': 'EducationalOrganization',
|
|
|
|
|
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,
|
|
|
|
|
}),
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
<>
|
|
|
|
|
<script
|
|
|
|
|
type="application/ld+json"
|
|
|
|
|
dangerouslySetInnerHTML={{ __html: JSON.stringify(structuredData) }}
|
|
|
|
|
/>
|
2026-03-28 22:36:00 +00:00
|
|
|
{isSecondary ? (
|
2026-08-02 21:39:21 +01:00
|
|
|
<SchoolDetailShell
|
2026-03-28 22:36:00 +00:00
|
|
|
schoolInfo={school_info}
|
|
|
|
|
yearlyData={yearly_data}
|
|
|
|
|
census={census ?? null}
|
2026-08-02 21:39:21 +01:00
|
|
|
navItems={secondaryNavItems}
|
|
|
|
|
>
|
|
|
|
|
<SecondarySchoolSections
|
|
|
|
|
schoolInfo={school_info}
|
|
|
|
|
yearlyData={yearly_data}
|
|
|
|
|
absenceData={absence_data}
|
|
|
|
|
ofsted={ofsted ?? null}
|
|
|
|
|
census={census ?? null}
|
|
|
|
|
admissions={admissions ?? null}
|
|
|
|
|
deprivation={deprivation ?? null}
|
|
|
|
|
finance={finance ?? null}
|
|
|
|
|
nationalAvg={nationalAvg}
|
|
|
|
|
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 ?? []}
|
|
|
|
|
deprivation={deprivation ?? null}
|
|
|
|
|
finance={finance ?? null}
|
|
|
|
|
nationalAvg={nationalAvg}
|
|
|
|
|
flags={primaryFlags}
|
|
|
|
|
/>
|
|
|
|
|
</SchoolDetailShell>
|
2026-03-28 22:36:00 +00:00
|
|
|
)}
|
2026-02-02 20:34:35 +00:00
|
|
|
</>
|
|
|
|
|
);
|
|
|
|
|
}
|