Files
school_compare/nextjs-app/app/school/[slug]/page.tsx
T
Tudor Sitaru 62eeee5f7c
Build and Push Docker Images / Build Backend (FastAPI) (push) Successful in 1m1s
Build and Push Docker Images / Build Frontend (Next.js) (push) Successful in 53s
Build and Push Docker Images / Build Pipeline (Meltano + dbt + Airflow) (push) Successful in 2m4s
Build and Push Docker Images / Trigger Portainer Update (push) Successful in 1s
perf: cache aggressively and trim client bundle
Frontend
- Dynamic-import Chart.js components on detail/compare views so Chart.js
  no longer ships in initial JS.
- Drop force-dynamic on home, compare, rankings so internal data fetches
  reuse Next.js's per-call revalidate cache.
- Switch /school/[slug] to ISR with a 7-day revalidate window (school
  data updates annually).
- Preconnect to analytics + postcodes.io; remove redundant defer on the
  Umami Script tag (afterInteractive already covers it).
- Bump images.minimumCacheTTL to 1 year.
- Extract HowItWorks and Editorial sections as server components passed
  to HomeView via slot props so their JSX stays out of the client bundle.

Backend
- Add GZipMiddleware (min 512 bytes).
- Add CacheAndETagMiddleware: per-path Cache-Control with long s-maxage
  + stale-while-revalidate, ETag generation, and 304 on If-None-Match.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-02 13:46:45 +01:00

218 lines
7.2 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 } from '@/lib/api';
import { notFound, redirect } from 'next/navigation';
import { SchoolDetailView } from '@/components/SchoolDetailView';
import { SecondarySchoolDetailView } from '@/components/SecondarySchoolDetailView';
import { parseSchoolSlug, schoolUrl } from '@/lib/utils';
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: `https://schoolcompare.co.uk${canonicalPath}`,
siteName: 'SchoolCompare',
},
twitter: {
card: 'summary',
title,
description,
},
alternates: {
canonical: `https://schoolcompare.co.uk${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
let data;
try {
data = await fetchSchoolDetails(urn);
} catch (error) {
console.error(`Failed to fetch school ${urn}:`, error);
notFound();
}
const { school_info, yearly_data, absence_data, ofsted, parent_view, census, admissions, sen_detail, phonics, deprivation, finance } = data;
// 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 SchoolDetailView (renders both KS2 + KS4 sections).
// SecondarySchoolDetailView 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)
);
// 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) }}
/>
{isSecondary ? (
<SecondarySchoolDetailView
schoolInfo={school_info}
yearlyData={yearly_data}
absenceData={absence_data}
ofsted={ofsted ?? null}
parentView={parent_view ?? null}
census={census ?? null}
admissions={admissions ?? null}
senDetail={sen_detail ?? null}
phonics={phonics ?? null}
deprivation={deprivation ?? null}
finance={finance ?? null}
/>
) : (
<SchoolDetailView
schoolInfo={school_info}
yearlyData={yearly_data}
absenceData={absence_data}
ofsted={ofsted ?? null}
parentView={parent_view ?? null}
census={census ?? null}
admissions={admissions ?? null}
senDetail={sen_detail ?? null}
phonics={phonics ?? null}
deprivation={deprivation ?? null}
finance={finance ?? null}
/>
)}
</>
);
}