feat(seo): add school name to URLs, fix sticky nav, collapse compare widget
Some checks failed
Build and Push Docker Images / Build Backend (FastAPI) (push) Successful in 32s
Build and Push Docker Images / Build Frontend (Next.js) (push) Failing after 57s
Build and Push Docker Images / Build Pipeline (Meltano + dbt + Airflow) (push) Successful in 31s
Build and Push Docker Images / Trigger Portainer Update (push) Has been skipped

- URLs now /school/138267-school-name instead of /school/138267
- Bare URN URLs redirect to canonical slug (backward compat)
- Remove overflow-x:hidden that broke sticky tab nav on secondary pages
- ComparisonToast starts collapsed — user must click to open

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-29 12:41:28 +01:00
parent e2c700fcfc
commit 784febc162
12 changed files with 63 additions and 30 deletions

View File

@@ -1,23 +1,25 @@
/**
* Individual School Page (SSR)
* Dynamic route for school details with full SEO optimization
* URL format: /school/138267-school-name-here
*/
import { fetchSchoolDetails } from '@/lib/api';
import { notFound } from 'next/navigation';
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';
interface SchoolPageProps {
params: Promise<{ urn: string }>;
params: Promise<{ slug: string }>;
}
export async function generateMetadata({ params }: SchoolPageProps): Promise<Metadata> {
const { urn: urnString } = await params;
const urn = parseInt(urnString);
const { slug } = await params;
const urn = parseSchoolSlug(slug);
if (isNaN(urn) || urn < 100000 || urn > 999999) {
if (!urn || urn < 100000 || urn > 999999) {
return {
title: 'School Not Found',
};
@@ -27,6 +29,7 @@ export async function generateMetadata({ params }: SchoolPageProps): Promise<Met
const data = await fetchSchoolDetails(urn);
const { school_info } = data;
const canonicalPath = schoolUrl(urn, school_info.school_name);
const isSecondary = (school_info.phase ?? '').toLowerCase().includes('secondary')
|| (data.yearly_data ?? []).some((d: any) => d.attainment_8_score != null);
const title = `${school_info.school_name} | ${school_info.local_authority || 'England'}`;
@@ -44,7 +47,7 @@ export async function generateMetadata({ params }: SchoolPageProps): Promise<Met
title,
description,
type: 'website',
url: `https://schoolcompare.co.uk/school/${urn}`,
url: `https://schoolcompare.co.uk${canonicalPath}`,
siteName: 'SchoolCompare',
},
twitter: {
@@ -53,7 +56,7 @@ export async function generateMetadata({ params }: SchoolPageProps): Promise<Met
description,
},
alternates: {
canonical: `https://schoolcompare.co.uk/school/${urn}`,
canonical: `https://schoolcompare.co.uk${canonicalPath}`,
},
};
} catch {
@@ -67,11 +70,11 @@ export async function generateMetadata({ params }: SchoolPageProps): Promise<Met
export const dynamic = 'force-dynamic';
export default async function SchoolPage({ params }: SchoolPageProps) {
const { urn: urnString } = await params;
const urn = parseInt(urnString);
const { slug } = await params;
const urn = parseSchoolSlug(slug);
// Validate URN format
if (isNaN(urn) || urn < 100000 || urn > 999999) {
if (!urn || urn < 100000 || urn > 999999) {
notFound();
}
@@ -86,6 +89,12 @@ export default async function SchoolPage({ params }: SchoolPageProps) {
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 isSecondary = (school_info.phase ?? '').toLowerCase().includes('secondary')
|| yearly_data.some((d: any) => d.attainment_8_score != null);

View File

@@ -5,6 +5,7 @@
import { MetadataRoute } from 'next';
import { fetchSchools } from '@/lib/api';
import { schoolUrl } from '@/lib/utils';
const BASE_URL = 'https://schoolcompare.co.uk';
@@ -39,7 +40,7 @@ export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
});
const schoolPages: MetadataRoute.Sitemap = schoolsData.schools.map((school) => ({
url: `${BASE_URL}/school/${school.urn}`,
url: `${BASE_URL}${schoolUrl(school.urn, school.school_name)}`,
lastModified: new Date(),
changeFrequency: 'monthly',
priority: 0.6,

View File

@@ -9,7 +9,7 @@ import styles from './ComparisonToast.module.css';
export function ComparisonToast() {
const { selectedSchools, clearAll, removeSchool } = useComparison();
const [mounted, setMounted] = useState(false);
const [collapsed, setCollapsed] = useState(false);
const [collapsed, setCollapsed] = useState(true);
const pathname = usePathname();
useEffect(() => {

View File

@@ -13,7 +13,7 @@ import { SchoolSearchModal } from './SchoolSearchModal';
import { EmptyState } from './EmptyState';
import { LoadingSkeleton } from './LoadingSkeleton';
import type { ComparisonData, MetricDefinition, School } from '@/lib/types';
import { formatPercentage, formatProgress, CHART_COLORS } from '@/lib/utils';
import { formatPercentage, formatProgress, CHART_COLORS, schoolUrl } from '@/lib/utils';
import { fetchComparison } from '@/lib/api';
import styles from './ComparisonView.module.css';
@@ -305,7 +305,7 @@ export function ComparisonView({
×
</button>
<h2 className={styles.schoolName}>
<a href={`/school/${school.urn}`}>{school.school_name}</a>
<a href={schoolUrl(school.urn, school.school_name)}>{school.school_name}</a>
</h2>
<div className={styles.schoolMeta}>
{school.local_authority && (

View File

@@ -15,6 +15,7 @@ import { EmptyState } from './EmptyState';
import { useComparisonContext } from '@/context/ComparisonContext';
import { fetchSchools, fetchLAaverages } from '@/lib/api';
import type { SchoolsResponse, Filters, School } from '@/lib/types';
import { schoolUrl } from '@/lib/utils';
import styles from './HomeView.module.css';
interface HomeViewProps {
@@ -316,7 +317,7 @@ function CompactSchoolItem({ school, onAddToCompare, isInCompare }: CompactSchoo
<div className={styles.compactItem}>
<div className={styles.compactItemContent}>
<div className={styles.compactItemHeader}>
<a href={`/school/${school.urn}`} className={styles.compactItemName}>
<a href={{schoolUrl(school.urn, school.school_name)}} className={styles.compactItemName}>
{school.school_name}
</a>
{school.distance !== undefined && school.distance !== null && (
@@ -352,7 +353,7 @@ function CompactSchoolItem({ school, onAddToCompare, isInCompare }: CompactSchoo
>
{isInCompare ? '✓ Comparing' : '+ Compare'}
</button>
<a href={`/school/${school.urn}`} className="btn btn-tertiary btn-sm">
<a href={{schoolUrl(school.urn, school.school_name)}} className="btn btn-tertiary btn-sm">
View
</a>
</div>

View File

@@ -9,6 +9,7 @@ import { useEffect, useRef } from 'react';
import L from 'leaflet';
import 'leaflet/dist/leaflet.css';
import type { School } from '@/lib/types';
import { schoolUrl } from '@/lib/utils';
// Fix for default marker icons in Next.js
delete (L.Icon.Default.prototype as any)._getIconUrl;
@@ -60,7 +61,7 @@ export default function LeafletMapInner({ schools, center, zoom, onMarkerClick }
<strong style="font-size: 14px; display: block; margin-bottom: 8px;">${school.school_name}</strong>
${school.local_authority ? `<div style="font-size: 12px; color: #666; margin-bottom: 4px;">${school.local_authority}</div>` : ''}
${school.school_type ? `<div style="font-size: 12px; color: #666; margin-bottom: 8px;">${school.school_type}</div>` : ''}
<a href="/school/${school.urn}" style="display: inline-block; margin-top: 8px; padding: 6px 12px; background: #e07256; color: white; text-decoration: none; border-radius: 4px; font-size: 12px;">View Details</a>
<a href="${schoolUrl(school.urn, school.school_name)}" style="display: inline-block; margin-top: 8px; padding: 6px 12px; background: #e07256; color: white; text-decoration: none; border-radius: 4px; font-size: 12px;">View Details</a>
</div>
`;

View File

@@ -8,7 +8,7 @@
import { useRouter, usePathname, useSearchParams } from 'next/navigation';
import { useComparison } from '@/hooks/useComparison';
import type { RankingEntry, Filters, MetricDefinition } from '@/lib/types';
import { formatPercentage, formatProgress } from '@/lib/utils';
import { formatPercentage, formatProgress, schoolUrl } from '@/lib/utils';
import { EmptyState } from './EmptyState';
import styles from './RankingsView.module.css';
@@ -267,7 +267,7 @@ export function RankingsView({
)}
</td>
<td className={styles.schoolCell}>
<a href={`/school/${ranking.urn}`} className={styles.schoolLink}>
<a href={{schoolUrl(ranking.urn, ranking.school_name)}} className={styles.schoolLink}>
{ranking.school_name}
</a>
</td>
@@ -277,7 +277,7 @@ export function RankingsView({
<strong>{displayValue}</strong>
</td>
<td className={styles.actionCell}>
<a href={`/school/${ranking.urn}`} className="btn btn-tertiary btn-sm">View</a>
<a href={{schoolUrl(ranking.urn, ranking.school_name)}} className="btn btn-tertiary btn-sm">View</a>
<button
onClick={() => handleAddToCompare(ranking)}
disabled={alreadyInComparison}

View File

@@ -5,7 +5,7 @@
import Link from 'next/link';
import type { School } from '@/lib/types';
import { formatPercentage, formatProgress, calculateTrend, getTrendColor } from '@/lib/utils';
import { formatPercentage, formatProgress, calculateTrend, getTrendColor, schoolUrl } from '@/lib/utils';
import styles from './SchoolCard.module.css';
interface SchoolCardProps {
@@ -25,7 +25,7 @@ export function SchoolCard({ school, onAddToCompare, onRemoveFromCompare, showDi
<div className={`${styles.card} ${isInCompare ? styles.cardInCompare : ''}`}>
<div className={styles.header}>
<h3 className={styles.title}>
<Link href={`/school/${school.urn}`}>
<Link href={{schoolUrl(school.urn, school.school_name)}}>
{school.school_name}
</Link>
</h3>
@@ -146,7 +146,7 @@ export function SchoolCard({ school, onAddToCompare, onRemoveFromCompare, showDi
)}
<div className={styles.actions}>
<Link href={`/school/${school.urn}`} className="btn btn-primary">
<Link href={{schoolUrl(school.urn, school.school_name)}} className="btn btn-primary">
View Details
</Link>
{onAddToCompare && (

View File

@@ -9,7 +9,7 @@
*/
import type { School } from '@/lib/types';
import { formatPercentage, formatProgress, calculateTrend } from '@/lib/utils';
import { formatPercentage, formatProgress, calculateTrend, schoolUrl } from '@/lib/utils';
import { progressBand } from '@/lib/metrics';
import styles from './SchoolRow.module.css';
@@ -61,7 +61,7 @@ export function SchoolRow({
{/* Line 1: School name + Ofsted badge */}
<div className={styles.line1}>
<a href={`/school/${school.urn}`} className={styles.schoolName}>
<a href={{schoolUrl(school.urn, school.school_name)}} className={styles.schoolName}>
{school.school_name}
</a>
{school.ofsted_grade && (
@@ -155,7 +155,7 @@ export function SchoolRow({
{/* Right: actions, vertically centred */}
<div className={styles.rowActions}>
<a href={`/school/${school.urn}`} className="btn btn-tertiary btn-sm">
<a href={{schoolUrl(school.urn, school.school_name)}} className="btn btn-tertiary btn-sm">
View
</a>
{(onAddToCompare || onRemoveFromCompare) && (

View File

@@ -2,7 +2,6 @@
.container {
width: 100%;
overflow-x: hidden;
}
/* ── Header ──────────────────────────────────────────── */

View File

@@ -11,7 +11,7 @@
'use client';
import type { School } from '@/lib/types';
import { schoolUrl } from '@/lib/utils';
import styles from './SecondarySchoolRow.module.css';
const OFSTED_LABELS: Record<number, string> = {
@@ -73,7 +73,7 @@ export function SecondarySchoolRow({
{/* Line 1: School name + Ofsted badge */}
<div className={styles.line1}>
<a href={`/school/${school.urn}`} className={styles.schoolName}>
<a href={{schoolUrl(school.urn, school.school_name)}} className={styles.schoolName}>
{school.school_name}
</a>
{school.ofsted_grade && (
@@ -155,7 +155,7 @@ export function SecondarySchoolRow({
{/* Right: actions */}
<div className={styles.rowActions}>
<a href={`/school/${school.urn}`} className="btn btn-tertiary btn-sm">
<a href={{schoolUrl(school.urn, school.school_name)}} className="btn btn-tertiary btn-sm">
View
</a>
{(onAddToCompare || onRemoveFromCompare) && (

View File

@@ -20,6 +20,28 @@ export function slugify(text: string): string {
.trim();
}
/**
* Build a school URL path: /school/123456-school-name (capped at ~80 chars total)
*/
const MAX_SLUG_LENGTH = 60;
export function schoolUrl(urn: number, schoolName?: string): string {
if (!schoolName) return `/school/${urn}`;
let slug = slugify(schoolName);
if (slug.length > MAX_SLUG_LENGTH) {
slug = slug.slice(0, MAX_SLUG_LENGTH).replace(/-+$/, '');
}
return `/school/${urn}-${slug}`;
}
/**
* Extract the URN from a school slug (e.g. "138267-some-school-name" → 138267)
*/
export function parseSchoolSlug(slug: string): number | null {
const match = slug.match(/^(\d{6})/);
return match ? parseInt(match[1], 10) : null;
}
/**
* Escape HTML to prevent XSS
*/