Complete Next.js migration with SSR and Docker deployment
Some checks failed
Build and Push Docker Images / Build Backend (FastAPI) (push) Successful in 1m26s
Build and Push Docker Images / Build Frontend (Next.js) (push) Failing after 1m48s
Build and Push Docker Images / Trigger Portainer Update (push) Has been skipped

- Migrate from vanilla JavaScript SPA to Next.js 16 with App Router
- Add server-side rendering for all pages (Home, Compare, Rankings)
- Create individual school pages with dynamic routing (/school/[urn])
- Implement Chart.js and Leaflet map integrations
- Add comprehensive SEO with sitemap, robots.txt, and JSON-LD
- Set up Docker multi-service architecture (PostgreSQL, FastAPI, Next.js)
- Update CI/CD pipeline to build both backend and frontend images
- Fix Dockerfile to include devDependencies for TypeScript compilation
- Add Jest testing configuration
- Implement performance optimizations (code splitting, caching)

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
This commit is contained in:
Tudor
2026-02-02 20:34:35 +00:00
parent f4919db3b9
commit ff7f5487e6
72 changed files with 18636 additions and 20 deletions

View File

@@ -0,0 +1,57 @@
/**
* Compare Page (SSR)
* Side-by-side comparison of schools with metrics
*/
import { fetchComparison, fetchMetrics } from '@/lib/api';
import { ComparisonView } from '@/components/ComparisonView';
import type { Metadata } from 'next';
interface ComparePageProps {
searchParams: Promise<{
urns?: string;
metric?: string;
}>;
}
export const metadata: Metadata = {
title: 'Compare Schools',
description: 'Compare KS2 performance across multiple primary schools in England',
keywords: 'school comparison, compare schools, KS2 comparison, primary school performance',
};
// Force dynamic rendering
export const dynamic = 'force-dynamic';
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';
// Fetch comparison data if URNs provided
let comparisonData = null;
if (urns.length > 0) {
try {
const response = await fetchComparison(urnsParam!);
comparisonData = response.comparison;
} catch (error) {
console.error('Failed to fetch comparison:', error);
}
}
// Fetch available metrics
const metricsResponse = await fetchMetrics();
// Convert metrics object to array
const metricsArray = Object.values(metricsResponse.metrics);
return (
<ComparisonView
initialData={comparisonData}
initialUrns={urns}
metrics={metricsArray}
selectedMetric={selectedMetric}
/>
);
}

277
nextjs-app/app/globals.css Normal file
View File

@@ -0,0 +1,277 @@
/* CSS Variables */
:root {
--primary: #3b82f6;
--primary-dark: #2563eb;
--secondary: #6b7280;
--success: #22c55e;
--danger: #ef4444;
--warning: #f59e0b;
--bg-primary: #ffffff;
--bg-secondary: #f9fafb;
--bg-tertiary: #f3f4f6;
--text-primary: #1f2937;
--text-secondary: #6b7280;
--text-tertiary: #9ca3af;
--border-light: #e5e7eb;
--border-medium: #d1d5db;
--border-dark: #9ca3af;
--shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.05);
--shadow-md: 0 4px 6px rgba(0, 0, 0, 0.07);
--shadow-lg: 0 10px 15px rgba(0, 0, 0, 0.1);
--radius-sm: 4px;
--radius-md: 6px;
--radius-lg: 8px;
--radius-xl: 12px;
--transition: 0.2s ease;
}
/* Reset and Base Styles */
* {
box-sizing: border-box;
padding: 0;
margin: 0;
}
html {
scroll-behavior: smooth;
}
html,
body {
max-width: 100vw;
overflow-x: hidden;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen',
'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue',
sans-serif;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
color: var(--text-primary);
background: var(--bg-secondary);
line-height: 1.6;
}
a {
color: inherit;
text-decoration: none;
}
/* App Container */
.app-container {
display: flex;
flex-direction: column;
min-height: 100vh;
position: relative;
}
.noise-overlay {
position: fixed;
inset: 0;
opacity: 0.03;
z-index: -1;
pointer-events: none;
background-image: url("data:image/svg+xml,%3Csvg viewBox='0 0 400 400' xmlns='http://www.w3.org/2000/svg'%3E%3Cfilter id='noiseFilter'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.9' numOctaves='3' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23noiseFilter)'/%3E%3C/svg%3E");
}
.main-content {
flex: 1;
max-width: 1280px;
width: 100%;
margin: 0 auto;
padding: 2rem 1.5rem;
}
/* Utility Classes */
.container {
max-width: 1280px;
width: 100%;
margin: 0 auto;
padding: 0 1.5rem;
}
.text-center {
text-align: center;
}
.mt-1 { margin-top: 0.5rem; }
.mt-2 { margin-top: 1rem; }
.mt-3 { margin-top: 1.5rem; }
.mt-4 { margin-top: 2rem; }
.mb-1 { margin-bottom: 0.5rem; }
.mb-2 { margin-bottom: 1rem; }
.mb-3 { margin-bottom: 1.5rem; }
.mb-4 { margin-bottom: 2rem; }
/* Grid Layouts */
.grid {
display: grid;
gap: 1.5rem;
}
.grid-1 {
grid-template-columns: 1fr;
}
.grid-2 {
grid-template-columns: repeat(2, 1fr);
}
.grid-3 {
grid-template-columns: repeat(3, 1fr);
}
.grid-auto {
grid-template-columns: repeat(auto-fill, minmax(320px, 1fr));
}
@media (max-width: 1024px) {
.grid-3 {
grid-template-columns: repeat(2, 1fr);
}
}
@media (max-width: 768px) {
.main-content {
padding: 1.5rem 1rem;
}
.grid-2,
.grid-3 {
grid-template-columns: 1fr;
}
}
/* Typography */
h1, h2, h3, h4, h5, h6 {
font-weight: 600;
line-height: 1.3;
color: var(--text-primary);
}
h1 {
font-size: 2.25rem;
margin-bottom: 1rem;
}
h2 {
font-size: 1.875rem;
margin-bottom: 0.875rem;
}
h3 {
font-size: 1.5rem;
margin-bottom: 0.75rem;
}
h4 {
font-size: 1.25rem;
margin-bottom: 0.625rem;
}
p {
margin-bottom: 1rem;
}
/* Buttons */
button {
font-family: inherit;
}
.btn {
display: inline-flex;
align-items: center;
justify-content: center;
padding: 0.625rem 1.25rem;
font-size: 0.9375rem;
font-weight: 500;
border: none;
border-radius: var(--radius-md);
cursor: pointer;
transition: all var(--transition);
text-decoration: none;
}
.btn-primary {
background: var(--primary);
color: white;
}
.btn-primary:hover {
background: var(--primary-dark);
}
.btn-secondary {
background: white;
color: var(--text-primary);
border: 1px solid var(--border-medium);
}
.btn-secondary:hover {
background: var(--bg-tertiary);
}
/* Form Elements */
input,
select,
textarea {
font-family: inherit;
font-size: 1rem;
}
input:focus,
select:focus,
textarea:focus {
outline: none;
}
/* Loading Spinner */
.spinner {
display: inline-block;
width: 1.5rem;
height: 1.5rem;
border: 3px solid rgba(59, 130, 246, 0.3);
border-radius: 50%;
border-top-color: var(--primary);
animation: spin 0.8s linear infinite;
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}
/* Scrollbar Styles */
::-webkit-scrollbar {
width: 10px;
height: 10px;
}
::-webkit-scrollbar-track {
background: var(--bg-tertiary);
}
::-webkit-scrollbar-thumb {
background: var(--border-dark);
border-radius: 5px;
}
::-webkit-scrollbar-thumb:hover {
background: var(--secondary);
}
/* Print Styles */
@media print {
.no-print {
display: none !important;
}
}

51
nextjs-app/app/layout.tsx Normal file
View File

@@ -0,0 +1,51 @@
import type { Metadata } from 'next';
import { Navigation } from '@/components/Navigation';
import { Footer } from '@/components/Footer';
import { ComparisonProvider } from '@/context/ComparisonProvider';
import './globals.css';
export const metadata: Metadata = {
title: {
default: 'SchoolCompare | Compare Primary School Performance',
template: '%s | SchoolCompare',
},
description: 'Compare primary school KS2 performance across England',
keywords: 'school comparison, KS2 results, primary school performance, England schools, SATs results',
authors: [{ name: 'SchoolCompare' }],
manifest: '/manifest.json',
openGraph: {
type: 'website',
title: 'SchoolCompare | Compare Primary School Performance',
description: 'Compare primary school KS2 performance across England',
url: 'https://schoolcompare.co.uk',
siteName: 'SchoolCompare',
},
twitter: {
card: 'summary',
title: 'SchoolCompare | Compare Primary School Performance',
description: 'Compare primary school KS2 performance across England',
},
};
export default function RootLayout({
children,
}: Readonly<{
children: React.ReactNode;
}>) {
return (
<html lang="en">
<body>
<ComparisonProvider>
<div className="app-container">
<div className="noise-overlay" />
<Navigation />
<main className="main-content">
{children}
</main>
<Footer />
</div>
</ComparisonProvider>
</body>
</html>
);
}

53
nextjs-app/app/page.tsx Normal file
View File

@@ -0,0 +1,53 @@
/**
* Home Page (SSR)
* Main landing page with school search and browsing
*/
import { fetchSchools, fetchFilters } from '@/lib/api';
import { HomeView } from '@/components/HomeView';
interface HomePageProps {
searchParams: {
search?: string;
local_authority?: string;
school_type?: string;
page?: string;
postcode?: string;
radius?: string;
};
}
export const metadata = {
title: 'Home',
description: 'Search and compare primary school KS2 performance across England',
};
// Force dynamic rendering (no static generation at build time)
export const dynamic = 'force-dynamic';
export default async function HomePage({ searchParams }: HomePageProps) {
// Parse search params
const page = parseInt(searchParams.page || '1');
const radius = searchParams.radius ? parseInt(searchParams.radius) : undefined;
// Fetch data on server
const [schoolsData, filtersData] = await Promise.all([
fetchSchools({
search: searchParams.search,
local_authority: searchParams.local_authority,
school_type: searchParams.school_type,
postcode: searchParams.postcode,
radius,
page,
page_size: 50,
}),
fetchFilters(),
]);
return (
<HomeView
initialSchools={schoolsData}
filters={filtersData.filters}
/>
);
}

View File

@@ -0,0 +1,58 @@
/**
* Rankings Page (SSR)
* Display top-ranked schools by various metrics
*/
import { fetchRankings, fetchFilters, fetchMetrics } from '@/lib/api';
import { RankingsView } from '@/components/RankingsView';
import type { Metadata } from 'next';
interface RankingsPageProps {
searchParams: Promise<{
metric?: string;
local_authority?: string;
year?: string;
}>;
}
export const metadata: Metadata = {
title: 'School Rankings',
description: 'Top-ranked primary schools by KS2 performance across England',
keywords: 'school rankings, top schools, best schools, KS2 rankings, school league tables',
};
// Force dynamic rendering
export const dynamic = 'force-dynamic';
export default async function RankingsPage({ searchParams }: RankingsPageProps) {
const { metric: metricParam, local_authority, year: yearParam } = await searchParams;
const metric = metricParam || 'rwm_expected_pct';
const year = yearParam ? parseInt(yearParam) : undefined;
// Fetch rankings data
const [rankingsResponse, filtersResponse, metricsResponse] = await Promise.all([
fetchRankings({
metric,
local_authority,
year,
limit: 100,
}),
fetchFilters(),
fetchMetrics(),
]);
// Convert metrics object to array
const metricsArray = Object.values(metricsResponse.metrics);
return (
<RankingsView
rankings={rankingsResponse.rankings}
filters={filtersResponse.filters}
metrics={metricsArray}
selectedMetric={metric}
selectedArea={local_authority}
selectedYear={year}
/>
);
}

19
nextjs-app/app/robots.ts Normal file
View File

@@ -0,0 +1,19 @@
/**
* Robots.txt Configuration
* Controls search engine crawling behavior
*/
import { MetadataRoute } from 'next';
export default function robots(): MetadataRoute.Robots {
return {
rules: [
{
userAgent: '*',
allow: '/',
disallow: ['/api/', '/_next/'],
},
],
sitemap: 'https://schoolcompare.co.uk/sitemap.xml',
};
}

View File

@@ -0,0 +1,122 @@
/**
* Individual School Page (SSR)
* Dynamic route for school details with full SEO optimization
*/
import { fetchSchoolDetails } from '@/lib/api';
import { notFound } from 'next/navigation';
import { SchoolDetailView } from '@/components/SchoolDetailView';
import type { Metadata } from 'next';
interface SchoolPageProps {
params: Promise<{ urn: string }>;
}
export async function generateMetadata({ params }: SchoolPageProps): Promise<Metadata> {
const { urn: urnString } = await params;
const urn = parseInt(urnString);
if (isNaN(urn) || urn < 100000 || urn > 999999) {
return {
title: 'School Not Found',
};
}
try {
const data = await fetchSchoolDetails(urn);
const { school_info } = data;
const title = `${school_info.school_name} | ${school_info.local_authority || 'England'}`;
const description = `View KS2 performance data, results, and statistics for ${school_info.school_name}${school_info.local_authority ? ` in ${school_info.local_authority}` : ''}. Compare reading, writing, and maths results.`;
return {
title,
description,
keywords: `${school_info.school_name}, KS2 results, primary school, ${school_info.local_authority}, school performance, SATs results`,
openGraph: {
title,
description,
type: 'website',
url: `https://schoolcompare.co.uk/school/${urn}`,
siteName: 'SchoolCompare',
},
twitter: {
card: 'summary',
title,
description,
},
alternates: {
canonical: `https://schoolcompare.co.uk/school/${urn}`,
},
};
} catch {
return {
title: 'School Not Found',
};
}
}
// Force dynamic rendering
export const dynamic = 'force-dynamic';
export default async function SchoolPage({ params }: SchoolPageProps) {
const { urn: urnString } = await params;
const urn = parseInt(urnString);
// Validate URN format
if (isNaN(urn) || urn < 100000 || urn > 999999) {
notFound();
}
// Fetch school data
let data;
try {
data = await fetchSchoolDetails(urn);
} catch (error) {
console.error(`Failed to fetch school ${urn}:`, error);
notFound();
}
const { school_info, yearly_data, absence_data } = data;
// Generate JSON-LD structured data for SEO
const structuredData = {
'@context': 'https://schema.org',
'@type': 'EducationalOrganization',
name: school_info.school_name,
identifier: school_info.urn.toString(),
...(school_info.address && {
address: {
'@type': 'PostalAddress',
streetAddress: school_info.address,
addressLocality: school_info.local_authority || undefined,
postalCode: school_info.postcode || undefined,
addressCountry: 'GB',
},
}),
...(school_info.latitude && school_info.longitude && {
geo: {
'@type': 'GeoCoordinates',
latitude: school_info.latitude,
longitude: school_info.longitude,
},
}),
...(school_info.school_type && {
additionalType: school_info.school_type,
}),
};
return (
<>
<script
type="application/ld+json"
dangerouslySetInnerHTML={{ __html: JSON.stringify(structuredData) }}
/>
<SchoolDetailView
schoolInfo={school_info}
yearlyData={yearly_data}
absenceData={absence_data}
/>
</>
);
}

54
nextjs-app/app/sitemap.ts Normal file
View File

@@ -0,0 +1,54 @@
/**
* Dynamic Sitemap Generation
* Generates sitemap with all school pages and main routes
*/
import { MetadataRoute } from 'next';
import { fetchSchools } from '@/lib/api';
const BASE_URL = 'https://schoolcompare.co.uk';
export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
// Static pages
const staticPages: MetadataRoute.Sitemap = [
{
url: BASE_URL,
lastModified: new Date(),
changeFrequency: 'daily',
priority: 1.0,
},
{
url: `${BASE_URL}/compare`,
lastModified: new Date(),
changeFrequency: 'weekly',
priority: 0.8,
},
{
url: `${BASE_URL}/rankings`,
lastModified: new Date(),
changeFrequency: 'weekly',
priority: 0.8,
},
];
// Fetch all schools (in batches if necessary)
try {
const schoolsData = await fetchSchools({
page: 1,
page_size: 10000, // Fetch all schools
});
const schoolPages: MetadataRoute.Sitemap = schoolsData.schools.map((school) => ({
url: `${BASE_URL}/school/${school.urn}`,
lastModified: new Date(),
changeFrequency: 'monthly',
priority: 0.6,
}));
return [...staticPages, ...schoolPages];
} catch (error) {
console.error('Failed to generate sitemap:', error);
// Return just static pages if school fetch fails
return staticPages;
}
}