diff --git a/backend/app.py b/backend/app.py
index f4da070..fa772d5 100644
--- a/backend/app.py
+++ b/backend/app.py
@@ -4,6 +4,7 @@ Serves primary and secondary school performance data for comparing schools.
Uses real data from UK Government Compare School Performance downloads.
"""
+import hashlib
import re
from contextlib import asynccontextmanager
from typing import Optional
@@ -12,6 +13,7 @@ import numpy as np
import pandas as pd
from fastapi import FastAPI, HTTPException, Query, Request, Depends, Header
from fastapi.middleware.cors import CORSMiddleware
+from fastapi.middleware.gzip import GZipMiddleware
from fastapi.responses import FileResponse, Response
from fastapi.staticfiles import StaticFiles
from slowapi import Limiter, _rate_limit_exceeded_handler
@@ -165,6 +167,69 @@ class SecurityHeadersMiddleware(BaseHTTPMiddleware):
return response
+# Per-path Cache-Control rules. Keys are matched as path prefixes (longest wins).
+# Values: (max_age, s_maxage, stale_while_revalidate)
+CACHE_RULES: list[tuple[str, tuple[int, int, int]]] = [
+ ("/api/filters", (300, 86400, 604800)),
+ ("/api/metrics", (300, 86400, 604800)),
+ ("/api/national-averages", (300, 86400, 604800)),
+ ("/api/la-averages", (300, 86400, 604800)),
+ ("/api/data-info", (300, 86400, 604800)),
+ ("/api/schools/", (300, 3600, 86400)), # /api/schools/{urn}
+ ("/api/rankings", (60, 600, 3600)),
+ ("/api/compare", (60, 600, 3600)),
+ ("/api/schools", (30, 300, 1800)), # search list
+]
+
+
+def _cache_control_for_path(path: str) -> Optional[str]:
+ # Longest-prefix match
+ best: Optional[tuple[int, tuple[int, int, int]]] = None
+ for prefix, vals in CACHE_RULES:
+ if path.startswith(prefix) and (best is None or len(prefix) > best[0]):
+ best = (len(prefix), vals)
+ if best is None:
+ return None
+ max_age, s_maxage, swr = best[1]
+ return f"public, max-age={max_age}, s-maxage={s_maxage}, stale-while-revalidate={swr}"
+
+
+class CacheAndETagMiddleware(BaseHTTPMiddleware):
+ """Set Cache-Control on cacheable API responses and serve 304s via ETag."""
+
+ async def dispatch(self, request: Request, call_next):
+ response = await call_next(request)
+
+ # Only cache GETs that succeeded.
+ if request.method != "GET" or response.status_code != 200:
+ return response
+
+ cache_header = _cache_control_for_path(request.url.path)
+ if cache_header is None:
+ return response
+
+ # Drain body so we can hash it for ETag.
+ body_chunks = []
+ async for chunk in response.body_iterator:
+ body_chunks.append(chunk)
+ body = b"".join(body_chunks)
+
+ etag = '"' + hashlib.md5(body).hexdigest() + '"'
+ headers = dict(response.headers)
+ headers["Cache-Control"] = cache_header
+ headers["ETag"] = etag
+ headers["Vary"] = ", ".join(filter(None, [headers.get("Vary"), "Accept-Encoding"]))
+
+ inm = request.headers.get("if-none-match")
+ if inm and inm == etag:
+ # Strip content headers on 304.
+ for h in ("Content-Length", "content-length", "Content-Type", "content-type"):
+ headers.pop(h, None)
+ return Response(status_code=304, headers=headers)
+
+ return Response(content=body, status_code=200, headers=headers, media_type=response.media_type)
+
+
class RequestSizeLimitMiddleware(BaseHTTPMiddleware):
"""Limit request body size to prevent DoS attacks."""
@@ -253,9 +318,12 @@ app = FastAPI(
app.state.limiter = limiter
app.add_exception_handler(RateLimitExceeded, _rate_limit_exceeded_handler)
-# Security middleware (order matters - these run in reverse order)
+# Middleware (Starlette runs the last-added middleware first on the way out,
+# so list outermost-last: GZip wraps everything and compresses the final body).
+app.add_middleware(CacheAndETagMiddleware)
app.add_middleware(SecurityHeadersMiddleware)
app.add_middleware(RequestSizeLimitMiddleware)
+app.add_middleware(GZipMiddleware, minimum_size=512)
# CORS middleware - restricted for production
app.add_middleware(
diff --git a/nextjs-app/app/compare/page.tsx b/nextjs-app/app/compare/page.tsx
index 4cdf2ff..1a1ff38 100644
--- a/nextjs-app/app/compare/page.tsx
+++ b/nextjs-app/app/compare/page.tsx
@@ -20,8 +20,8 @@ export const metadata: Metadata = {
keywords: 'school comparison, compare schools, KS2 comparison, primary school performance',
};
-// Force dynamic rendering
-export const dynamic = 'force-dynamic';
+// Dynamic via searchParams; remove force-dynamic so internal data fetches
+// can still use Next.js's per-call revalidate cache.
export default async function ComparePage({ searchParams }: ComparePageProps) {
const { urns: urnsParam, metric: metricParam } = await searchParams;
diff --git a/nextjs-app/app/layout.tsx b/nextjs-app/app/layout.tsx
index d7229fd..0ee5b9b 100644
--- a/nextjs-app/app/layout.tsx
+++ b/nextjs-app/app/layout.tsx
@@ -74,8 +74,9 @@ export default function RootLayout({
return (
+
+
}
+ editorial={hasSearchParams ? null : }
/>
);
} catch (error) {
console.error('Error fetching data for home page:', error);
- // Return error state with empty data
+ const emptyFilters = { local_authorities: [], school_types: [], years: [], phases: [], genders: [], admissions_policies: [] };
return (
}
+ editorial={hasSearchParams ? null : }
/>
);
}
diff --git a/nextjs-app/app/rankings/page.tsx b/nextjs-app/app/rankings/page.tsx
index 96f8882..15b2c02 100644
--- a/nextjs-app/app/rankings/page.tsx
+++ b/nextjs-app/app/rankings/page.tsx
@@ -22,8 +22,8 @@ export const metadata: Metadata = {
keywords: 'school rankings, top schools, best schools, KS2 rankings, KS4 rankings, school league tables',
};
-// Force dynamic rendering
-export const dynamic = 'force-dynamic';
+// Dynamic via searchParams; remove force-dynamic so internal data fetches
+// can still use Next.js's per-call revalidate cache.
export default async function RankingsPage({ searchParams }: RankingsPageProps) {
const { metric: metricParam, local_authority, year: yearParam, phase: phaseParam } = await searchParams;
diff --git a/nextjs-app/app/school/[slug]/page.tsx b/nextjs-app/app/school/[slug]/page.tsx
index 671afcd..6a7b804 100644
--- a/nextjs-app/app/school/[slug]/page.tsx
+++ b/nextjs-app/app/school/[slug]/page.tsx
@@ -4,13 +4,48 @@
* URL format: /school/138267-school-name-here
*/
-import { fetchSchoolDetails } from '@/lib/api';
+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> {
+ 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 }>;
}
@@ -75,8 +110,10 @@ export async function generateMetadata({ params }: SchoolPageProps): Promise import('./ComparisonChart').then((m) => m.ComparisonChart),
+ { ssr: false },
+);
import { SchoolSearchModal } from './SchoolSearchModal';
import { EmptyState } from './EmptyState';
import { LoadingSkeleton } from './LoadingSkeleton';
diff --git a/nextjs-app/components/EditorialSection.tsx b/nextjs-app/components/EditorialSection.tsx
new file mode 100644
index 0000000..318361c
--- /dev/null
+++ b/nextjs-app/components/EditorialSection.tsx
@@ -0,0 +1,59 @@
+// Server component: pure markup, no client state.
+
+import styles from './HomeView.module.css';
+
+interface EditorialSectionProps {
+ totalSchools: number | null;
+ localAuthorityCount: number;
+}
+
+export function EditorialSection({ totalSchools, localAuthorityCount }: EditorialSectionProps) {
+ return (
+
+
+
+
About school data
+
Making UK school performance data actually readable
+
+ School performance data in England is rich but fragmented. The Department for Education publishes
+ Key Stage 2 SATs, GCSE attainment, Ofsted outcomes, progress scores, admissions figures and
+ demographics — each in its own table, each with its own jargon.
+
+
+ SchoolCompare brings it all into one place. Every school page shows performance against the national
+ average, explains what the numbers mean, and lets you shortlist schools side by side. Built for
+ parents, governors, journalists, and anyone who wants to understand a school without reading a
+ 40-page inspection report.
+
For primary schools, each subject's Expected and Exceeding percentages side by side. For secondary schools, GCSE Attainment 8 with the national benchmark overlaid.
-
-
-
- {/* Card 2 — Ofsted */}
-
-
-
-
-
- Latest Ofsted inspection
-
- OUTSTANDING
-
Rated Outstanding at last inspection.
-
Full inspection · March 2024
-
-
-
-
Judgement
-
Ofsted at a glance
-
Current grade, inspection date, and a plain-English headline — without opening a 40-page report.
Pin up to five schools and every metric aligns in the same columns — works for primary and secondary alike.
-
-
-
-
-
- )}
-
- {/* Editorial — only on landing page */}
- {!isSearchActive && (
-
-
-
-
About school data
-
Making UK school performance data actually readable
-
- School performance data in England is rich but fragmented. The Department for Education publishes
- Key Stage 2 SATs, GCSE attainment, Ofsted outcomes, progress scores, admissions figures and
- demographics — each in its own table, each with its own jargon.
-
-
- SchoolCompare brings it all into one place. Every school page shows performance against the national
- average, explains what the numbers mean, and lets you shortlist schools side by side. Built for
- parents, governors, journalists, and anyone who wants to understand a school without reading a
- 40-page inspection report.
-
For primary schools, each subject's Expected and Exceeding percentages side by side. For secondary schools, GCSE Attainment 8 with the national benchmark overlaid.
+
+
+
+ {/* Card 2 — Ofsted */}
+
+
+
+
+
+ Latest Ofsted inspection
+
+ OUTSTANDING
+
Rated Outstanding at last inspection.
+
Full inspection · March 2024
+
+
+
+
Judgement
+
Ofsted at a glance
+
Current grade, inspection date, and a plain-English headline — without opening a 40-page report.
+
+
+
+ {/* Card 3 — Compare */}
+
+
+
+
+
Metric
+
Our Lady Queen of Heaven
+
St Mary's Catholic Primary
+
+ {compareRows.map(({ label, a, b, aHi }) => (
+
+ {label}
+ {a}
+ {b}
+
+ ))}
+
+ pin up to 5 schools
+
+
+
+
Compare
+
Side-by-side shortlists
+
Pin up to five schools and every metric aligns in the same columns — works for primary and secondary alike.