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
150 lines
5.4 KiB
TypeScript
150 lines
5.4 KiB
TypeScript
/**
|
|
* Home Page (SSR)
|
|
* Main landing page with school search and browsing
|
|
*/
|
|
|
|
import { absoluteUrl } from '@/lib/site';
|
|
import type { Metadata } from 'next';
|
|
import { fetchSchools, fetchFilters, fetchDataInfo } from '@/lib/api';
|
|
import { formatAcademicYear } from '@/lib/utils';
|
|
import { HomeView } from '@/components/HomeView';
|
|
import { HowItWorksSection } from '@/components/HowItWorksSection';
|
|
import { EditorialSection } from '@/components/EditorialSection';
|
|
|
|
interface HomePageProps {
|
|
searchParams: Promise<{
|
|
search?: string;
|
|
local_authority?: string;
|
|
school_type?: string;
|
|
phase?: string;
|
|
page?: string;
|
|
postcode?: string;
|
|
radius?: string;
|
|
sort?: string;
|
|
gender?: string;
|
|
admissions_policy?: string;
|
|
has_sixth_form?: string;
|
|
}>;
|
|
}
|
|
|
|
/*
|
|
* `title` was the bare string 'Home', which is what the browser tab, the
|
|
* bookmark and the search result all read. `absolute` opts out of the root
|
|
* layout's "%s | schoolcompare" template so the homepage doesn't end up
|
|
* saying the brand twice.
|
|
*/
|
|
export const metadata: Metadata = {
|
|
/*
|
|
* Intent in the title, differentiator in the description.
|
|
*
|
|
* These queries are owned by the DfE's own "Compare school performance"
|
|
* service, and the old title — brand first, then a near-paraphrase of that
|
|
* service's name — gave a searcher no reason to pick us over it. It drew
|
|
* 0.43% CTR at position 6.1 while the brand query drew 9.16% from the same
|
|
* neighbourhood, so the ranking was never the problem.
|
|
*
|
|
* The title now matches what people type. The description carries the one
|
|
* fact gov.uk does not publish: how close you had to live to get a place.
|
|
*/
|
|
title: { absolute: 'Compare Schools Side by Side | schoolcompare' },
|
|
description:
|
|
'Put five English schools on one screen — SATs, GCSE results, Ofsted grades, and how close you had to live to get a place. Free, no sign-up.',
|
|
// This page reads eleven search params. They filter a result set; they do
|
|
// not make a new document. Collapsing every combination onto "/" stops the
|
|
// homepage competing with itself for its own head terms.
|
|
alternates: { canonical: absoluteUrl('/') },
|
|
};
|
|
|
|
// The page reads searchParams, which makes rendering dynamic by default.
|
|
// We don't use `force-dynamic` here so the internal filter/data-info fetches
|
|
// can still hit Next.js's data cache (configured per-call in lib/api.ts).
|
|
|
|
export default async function HomePage({ searchParams }: HomePageProps) {
|
|
// Await search params (Next.js 15 requirement)
|
|
const params = await searchParams;
|
|
|
|
// Parse search params
|
|
const page = parseInt(params.page || '1');
|
|
const radius = params.radius ? parseFloat(params.radius) : undefined;
|
|
|
|
// Check if user has performed a search
|
|
const hasSearchParams = !!(
|
|
params.search ||
|
|
params.local_authority ||
|
|
params.school_type ||
|
|
params.phase ||
|
|
params.postcode ||
|
|
params.gender ||
|
|
params.admissions_policy ||
|
|
params.has_sixth_form
|
|
);
|
|
|
|
// Fetch data on server with error handling
|
|
try {
|
|
const [filtersData, dataInfo] = await Promise.all([fetchFilters(), fetchDataInfo().catch(() => null)]);
|
|
|
|
// Only fetch schools if there are search parameters
|
|
let schoolsData;
|
|
if (hasSearchParams) {
|
|
schoolsData = await fetchSchools({
|
|
search: params.search,
|
|
local_authority: params.local_authority,
|
|
school_type: params.school_type,
|
|
phase: params.phase,
|
|
postcode: params.postcode,
|
|
radius,
|
|
page,
|
|
page_size: 50,
|
|
gender: params.gender,
|
|
admissions_policy: params.admissions_policy,
|
|
has_sixth_form: params.has_sixth_form,
|
|
});
|
|
} else {
|
|
// Empty state by default
|
|
schoolsData = { schools: [], page: 1, page_size: 50, total: 0, total_pages: 0 };
|
|
}
|
|
|
|
const resolvedFilters = filtersData || { local_authorities: [], school_types: [], years: [], phases: [], genders: [], admissions_policies: [] };
|
|
// `unique_schools`, not `total_schools` — the latter is not a field this
|
|
// endpoint returns, and reading it silently yielded null on every request.
|
|
const total = dataInfo?.unique_schools ?? null;
|
|
const years = dataInfo?.years_available ?? [];
|
|
return (
|
|
<HomeView
|
|
initialSchools={schoolsData}
|
|
filters={resolvedFilters}
|
|
totalSchools={total}
|
|
howItWorks={hasSearchParams ? null : <HowItWorksSection />}
|
|
editorial={hasSearchParams ? null : (
|
|
<EditorialSection
|
|
totalSchools={total}
|
|
localAuthorityCount={resolvedFilters.local_authorities.length}
|
|
earliestYearLabel={years.length ? formatAcademicYear(years[0]) : null}
|
|
latestYearLabel={years.length ? formatAcademicYear(years[years.length - 1]) : null}
|
|
/>
|
|
)}
|
|
/>
|
|
);
|
|
} catch (error) {
|
|
console.error('Error fetching data for home page:', error);
|
|
|
|
const emptyFilters = { local_authorities: [], school_types: [], years: [], phases: [], genders: [], admissions_policies: [] };
|
|
return (
|
|
<HomeView
|
|
initialSchools={{ schools: [], page: 1, page_size: 50, total: 0, total_pages: 0 }}
|
|
filters={emptyFilters}
|
|
totalSchools={null}
|
|
howItWorks={hasSearchParams ? null : <HowItWorksSection />}
|
|
editorial={hasSearchParams ? null : (
|
|
<EditorialSection
|
|
totalSchools={null}
|
|
localAuthorityCount={0}
|
|
earliestYearLabel={null}
|
|
latestYearLabel={null}
|
|
/>
|
|
)}
|
|
/>
|
|
);
|
|
}
|
|
}
|