/** * Home Page (SSR) * Main landing page with school search and browsing */ import { absoluteUrl } from '@/lib/site'; import type { Metadata } from 'next'; import { headers } from 'next/headers'; import { isHandheldUserAgent } from '@/lib/device'; import { fetchSchools, fetchFilters, fetchDataInfo } from '@/lib/api'; import { formatAcademicYear } from '@/lib/utils'; import { HomeView } from '@/components/HomeView'; import { getFlags } from '@/lib/flags'; 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; // Server-read: no flag value reaches the browser bundle. Threaded down to // both FilterBar instances via HomeView. const flags = await getFlags(); const autosuggest = flags.school_autosuggest === true; // 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 ); // Failures propagate to the retryable error boundary. 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 ?? []; /* * A postcode search opens on the map for desktop browsers and on the list * for phones and tablets. The user agent is the only signal available * before the first paint; HomeView falls back to the list if the window * turns out too narrow, so a wrong guess costs one swap, not a broken page. */ const initialView = params.postcode && !isHandheldUserAgent((await headers()).get('user-agent')) ? 'map' : 'list'; return ( } editorial={hasSearchParams ? null : ( )} /> ); }