PR Checks / Frontend Typecheck + Tests (pull_request) Successful in 1m3s
PR Checks / Backend Smoke (pull_request) Successful in 7s
PR Checks / Build Backend (no push) (pull_request) Successful in 11s
PR Checks / Build Frontend (no push) (pull_request) Successful in 44s
PR Checks / Build Pipeline (no push) (pull_request) Successful in 10s
PR Checks / AI Code Review (Claude) (pull_request) Failing after 11s
The baseline says these pages already rank and are not clicked. 'compare school performance' sits at position 6.1 with 0.43% CTR; 'compare schools' at 7.2 with 0.87%. The brand query 'school compare' draws 9.16% from the same neighbourhood of the same results page, which rules out a ranking explanation — when the snippet gives a reason to click, it gets clicked. These SERPs are owned by the DfE's own 'Compare school performance' service. The old title put a lowercase brand nobody searches for in the most valuable pixels, then a near-paraphrase of that service's name. Beside the government's own result it read as a lookalike. Intent in the title, differentiator in the description. Titles now match what people type, and the descriptions carry the one fact gov.uk does not publish: how close you had to live to get a place. /compare deliberately takes the tool phrasing rather than the homepage's, so the two pages stop competing for one phrase. The root layout's default and Open Graph copy were saying something different again; they now agree. No hard school counts in any of it. The corpus moves with every data refresh and this repo has already shipped one copy bug of that kind. Tests guard the mechanics — SERP length, intent keyword, the differentiator, no brand-first title — and leave the wording free to iterate. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015mWQnpye9F299NVRCCSRvj
96 lines
3.1 KiB
TypeScript
96 lines
3.1 KiB
TypeScript
/**
|
|
* Compare Page (SSR)
|
|
* Side-by-side comparison of schools with metrics
|
|
*/
|
|
|
|
import { fetchComparison, fetchMetrics } from '@/lib/api';
|
|
import { ComparisonView } from '@/components/ComparisonView';
|
|
import { absoluteUrl } from '@/lib/site';
|
|
import type { Metadata } from 'next';
|
|
|
|
interface ComparePageProps {
|
|
searchParams: Promise<{
|
|
urns?: string;
|
|
metric?: string;
|
|
}>;
|
|
}
|
|
|
|
/**
|
|
* Indexability depends on the query string, so this cannot be a static export.
|
|
*
|
|
* Bare /compare is the landing page for the "compare schools" head term and
|
|
* stays indexable. /compare?urns=… is an unbounded parameter space — 25,193
|
|
* schools make ~317 million pairs — so it goes noindex. It stays `follow` and
|
|
* keeps a canonical to the bare path, so the links out to each school page
|
|
* still count.
|
|
*/
|
|
export async function generateMetadata(
|
|
{ searchParams }: ComparePageProps,
|
|
): Promise<Metadata> {
|
|
const { urns } = await searchParams;
|
|
|
|
const base: Metadata = {
|
|
// Deliberately not the homepage's phrase. Two pages chasing "compare
|
|
// schools" is how a site competes with itself; this one takes the tool
|
|
// phrasing instead.
|
|
title: 'School Comparison Tool — Up to Five at Once | schoolcompare',
|
|
description:
|
|
'Put up to five English schools in one table: SATs and GCSE results against the England average, Ofsted grades, and the distance places were offered.',
|
|
keywords:
|
|
'school comparison, compare schools, Ofsted comparison, school admissions, KS2 comparison, primary school performance',
|
|
alternates: { canonical: absoluteUrl('/compare') },
|
|
};
|
|
|
|
if (!urns) return base;
|
|
|
|
return { ...base, robots: { index: false, follow: true } };
|
|
}
|
|
|
|
// 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;
|
|
|
|
const urns = urnsParam?.split(',').map(Number).filter(Boolean) || [];
|
|
const selectedMetric = metricParam || 'rwm_expected_pct';
|
|
|
|
try {
|
|
// Fetch comparison + metrics in parallel — they are independent.
|
|
const [comparisonResponse, metricsResponse] = await Promise.all([
|
|
urns.length > 0
|
|
? fetchComparison(urnsParam!).catch((error) => {
|
|
console.error('Failed to fetch comparison:', error);
|
|
return null;
|
|
})
|
|
: Promise.resolve(null),
|
|
fetchMetrics(),
|
|
]);
|
|
|
|
const metricsArray = metricsResponse?.metrics || [];
|
|
|
|
return (
|
|
<ComparisonView
|
|
initialData={comparisonResponse?.comparison ?? null}
|
|
initialNationalAverages={comparisonResponse?.national_averages}
|
|
initialBenchmarks={comparisonResponse?.benchmarks}
|
|
initialUrns={urns}
|
|
metrics={metricsArray}
|
|
selectedMetric={selectedMetric}
|
|
/>
|
|
);
|
|
} catch (error) {
|
|
console.error('Error fetching data for compare page:', error);
|
|
|
|
// Return error state with empty metrics
|
|
return (
|
|
<ComparisonView
|
|
initialData={null}
|
|
initialUrns={urns}
|
|
metrics={[]}
|
|
selectedMetric={selectedMetric}
|
|
/>
|
|
);
|
|
}
|
|
}
|