{post.title}
By Tudor {' · '}
{/* A plain# About Page and Payload Blog Implementation Plan > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. **Goal:** Give schoolcompare a named human author via an `/about` page and a Payload-CMS-backed blog at `/blog`, both served from the existing Next.js app. **Architecture:** Payload 3 installs into the existing Next application and serves `/admin` from the same container, storing content in its own `payload` schema on the existing Postgres instance. Because Payload's admin panel needs to be a *root* layout, the existing site routes first move into an `app/(frontend)/` route group (URLs are unchanged — route groups are invisible to routing). Blog pages use ISR with on-demand revalidation, because CI builds the image with no database reachable. **Tech Stack:** Next.js 16 (App Router), React 19, TypeScript, Payload CMS 3 (`@payloadcms/db-postgres`, `@payloadcms/next`, `@payloadcms/richtext-lexical`), PostgreSQL, CSS Modules, Jest, Playwright, Docker. **Spec:** `docs/superpowers/specs/2026-09-02-about-and-blog-design.md` ## Global Constraints These apply to every task. Copied from the spec. - **Author identity:** first name **Tudor** only. Never a surname. Never an employer name. Never children's names. Location is "south-west London". - **Credibility claim:** the About page must state plainly *"I'm not an education expert."* Authority comes from lived experience (a parent going through primary admissions) and from stated provenance of every number — never from claimed educational qualifications. - **Voice:** first person singular ("I built", never "we provide"); concrete over general; state limits before someone else finds them; no mission statements, no "passionate about", no invented team; short sentences. - **Navigation is not touched.** `Navigation.tsx`'s mobile bottom tab bar already carries four items; About and Blog are footer-only. Post pages link to `/about` via the byline. - **Design tokens only.** Use the existing CSS custom properties from `app/globals.css` (`--bg-primary`, `--bg-card`, `--text-primary`, `--text-secondary`, `--text-muted`, `--border`, `--brand`, `--brand-strong`, `--brand-bg`, `--surface-sunken`). Never hardcode a hex value. Every new surface must work in both light and dark themes. - **Typography:** `var(--font-display)` (Manrope) for headings, `var(--font-ui)` (Inter) for body. Never introduce a third family. - **Absolute URLs** come from `absoluteUrl()` in `lib/site.ts`. Never hardcode `https://www.schoolcompare.co.uk`. - **No secrets committed.** `PAYLOAD_SECRET` and `DATABASE_URL` are supplied by the environment. Staging must use different values from production. - **CLAUDE.md rule:** user-facing behaviour changes ship with `e2e/` journey updates in the same PR. - **CLAUDE.md rule:** do not attempt to start a local server to test the application — it does not work. Verification is via Jest, `npm run build`, `npm run typecheck`, and the post-merge staging e2e gate. --- ## Two structural findings that changed this plan Read these before starting. They were discovered while planning, not while writing the spec, and they are the two places this work can go badly wrong. **1. The existing routes must move into `app/(frontend)/`.** Next.js requires the *root* layout to render `` and `
`. Payload's admin panel ships its own root layout that also renders ``/``. With today's `app/layout.tsx` in place, Payload's layout would nest inside the site's — inheriting the nav, footer, fonts and `ComparisonProvider` — and produce nested `` elements. The supported pattern is multiple root layouts via route groups, which requires that **no `app/layout.tsx` exists**. So every current route moves into `app/(frontend)/`. Route groups do not appear in URLs, so every public URL is byte-identical afterwards. The move is mechanical but it touches every route in the app, which is why it is its own task (Task 2) with the full test suite as its gate. **2. The `/api` collision is avoided by the remap alone.** The spec proposed also adding `/cms-api` to the FastAPI proxy's exclusion list as a second line of defence. That is unnecessary and would be dead code: the proxy is `app/api/[...path]/route.ts`, which only ever matches `/api/*`, and `/cms-api` is a sibling path that never reaches it. Remapping Payload's API route to `/cms-api` is sufficient and complete. Do not add the exclusion. --- ## File Structure **Moved (Task 2)** — `app/*` → `app/(frontend)/*`, unchanged in content: `layout.tsx`, `page.tsx`, `globals.css`, `rankings/`, `admissions/`, `compare/`, `schools/`, `school/`, `api/`, `sitemaps/`, `sitemap.xml/`. **Deliberately NOT moved — they stay at the `app/` root:** `robots.ts`, `opengraph-image.tsx`, `icon.png`, `apple-icon.png`. Next.js metadata file conventions only produce stable root URLs at the `app/` root. Inside a route group they are treated as segment-scoped: verified during execution, moving them into `(frontend)` renamed `/icon.png` to `/icon-4usi79.png`, `/apple-icon.png` to `/apple-icon-4usi79.png`, `/opengraph-image` to `/opengraph-image-4usi79`, and dropped `/robots.txt` entirely. That would have broken the `/icon.png` cache-control rule and the `outputFileTracingIncludes['/opengraph-image']` entry in `next.config.mjs`, and silently removed the site's robots.txt. Route handlers (`sitemap.xml/`, `sitemaps/`, `api/`) are unaffected and move normally. **Created:** | Path | Responsibility | |---|---| | `nextjs-app/next.config.mjs` | Replaces `next.config.js`; ESM so it can wrap `withPayload()` | | `nextjs-app/payload.config.ts` | Payload's single source of truth: DB, routes, collections | | `nextjs-app/collections/Users.ts` | Admin auth collection, lockout policy, closed registration | | `nextjs-app/collections/Posts.ts` | Blog post schema, drafts, revalidation hooks | | `nextjs-app/collections/Media.ts` | Upload collection writing to `/app/media` | | `nextjs-app/blocks/Callout.ts` | The "what this number doesn't tell you" block | | `nextjs-app/app/(payload)/**` | Generated Payload admin + `/cms-api` routes | | `nextjs-app/migrations/**` | Committed Payload schema migrations | | `nextjs-app/app/(frontend)/about/page.tsx` + `.module.css` | The About page | | `nextjs-app/app/(frontend)/blog/page.tsx` + `.module.css` | Blog index | | `nextjs-app/app/(frontend)/blog/[slug]/page.tsx` + `.module.css` | Post page | | `nextjs-app/app/(frontend)/blog/rss.xml/route.ts` | RSS feed | | `nextjs-app/app/(frontend)/content-sitemap.xml/route.ts` | Sitemap for Next-owned URLs | | `nextjs-app/lib/payload.ts` | Cached `getPayload()` accessor | | `nextjs-app/lib/jsonld.ts` | `Person` / `Organization` / `BlogPosting` builders | **Modified:** `package.json`, `tsconfig.json`, `Dockerfile`, `docker-compose.portainer.yml`, `docker-compose.portainer.staging.yml`, `components/Footer.tsx`, `__tests__/app/metadata.test.ts`, `__tests__/api/proxyDenylist.test.ts`, `__tests__/app/placeMetadata.test.ts`, `e2e/tests/journeys.spec.ts`. --- ### Task 1: Convert `next.config.js` to ESM `withPayload()` is ESM-only, so the config must become `.mjs`. This file also carries the rule that keeps staging out of Google's index — the highest-value thing in the repo to break silently — so it is converted first, on its own, behind a test. **Files:** - Create: `nextjs-app/__tests__/app/nextConfig.test.ts` - Create: `nextjs-app/next.config.mjs` - Delete: `nextjs-app/next.config.js` **Interfaces:** - Consumes: nothing. - Produces: `next.config.mjs` default-exporting the Next config object. Task 3 wraps this export in `withPayload()`. - [ ] **Step 1: Write the failing test** Create `nextjs-app/__tests__/app/nextConfig.test.ts`: ```ts /** * next.config.mjs carries the staging noindex rule. Breaking it turns * stx.schoolcompare.co.uk into a fully crawlable duplicate of production, * and nothing else in the suite would notice. */ import nextConfig from '@/next.config.mjs'; describe('next.config.mjs', () => { it('keeps the staging host out of the index', async () => { const headers = await nextConfig.headers(); const stagingRule = headers.find((rule) => rule.has?.some( (cond) => cond.type === 'host' && cond.value === 'stx.schoolcompare.co.uk', ), ); expect(stagingRule).toBeDefined(); expect(stagingRule.headers).toContainEqual({ key: 'X-Robots-Tag', value: 'noindex, nofollow', }); }); it('still emits standalone output for the Docker runner', () => { expect(nextConfig.output).toBe('standalone'); }); it('still traces the share-card fonts into the standalone bundle', () => { expect(nextConfig.outputFileTracingIncludes['/opengraph-image']).toEqual([ './assets/**', ]); }); it('still allows the analytics subdomain to frame the site', async () => { const headers = await nextConfig.headers(); const csp = headers .flatMap((rule) => rule.headers) .find((header) => header.key === 'Content-Security-Policy'); expect(csp.value).toContain('https://analytics.schoolcompare.co.uk'); }); }); ``` - [ ] **Step 2: Run the test to verify it fails** ```bash cd nextjs-app && npm test -- __tests__/app/nextConfig.test.ts ``` Expected: FAIL — cannot resolve `@/next.config.mjs` (the file does not exist yet). If it instead fails with an ESM parse error after Step 3, add `'mjs'` to `moduleFileExtensions` in `jest.config.js`: ```js moduleFileExtensions: ['ts', 'tsx', 'js', 'jsx', 'mjs', 'json', 'node'], ``` - [ ] **Step 3: Convert the config** ```bash cd nextjs-app && git mv next.config.js next.config.mjs ``` Then edit `next.config.mjs`: change the final line from `module.exports = nextConfig;` to `export default nextConfig;`. Change nothing else. Every comment block in that file documents a past production incident — the baked-in `FASTAPI_URL`, the staging `X-Robots-Tag` reasoning, the `/icon.png` cache 404 — and all of it must survive verbatim. - [ ] **Step 4: Run the test to verify it passes** ```bash cd nextjs-app && npm test -- __tests__/app/nextConfig.test.ts ``` Expected: PASS, 4 tests. - [ ] **Step 5: Verify the whole suite and the build still pass** ```bash cd nextjs-app && npm test && npm run typecheck && npm run build ``` Expected: all green. `next/jest` resolves `.mjs` configs, so the existing suite is unaffected. - [ ] **Step 6: Commit** ```bash git add nextjs-app/next.config.mjs nextjs-app/__tests__/app/nextConfig.test.ts nextjs-app/jest.config.js git commit -m "build(next): convert the config to ESM so Payload can wrap it withPayload() is ESM-only. This file also carries the rule that keeps staging out of the index, so the conversion goes in on its own behind a test that asserts the rule survived." ``` --- ### Task 2: Move site routes into an `app/(frontend)` route group This creates room for Payload's admin panel to be its own root layout. No URL changes: route groups are invisible to routing. **Files:** - Move: everything currently under `nextjs-app/app/` → `nextjs-app/app/(frontend)/` - Modify: `nextjs-app/__tests__/app/metadata.test.ts:1-4` - Modify: `nextjs-app/__tests__/app/placeMetadata.test.ts:1` - Modify: `nextjs-app/__tests__/api/proxyDenylist.test.ts:11` **Interfaces:** - Consumes: Task 1's `next.config.mjs`. - Produces: no `app/layout.tsx` at the app root — the precondition Task 3 requires. Site routes importable as `@/app/(frontend)/Who's behind this
I'm a parent in south-west London. When we started looking at primary schools, I found the information I needed was all published — and almost impossible to hold in one place.
SATs results were in one government table. Ofsted judgements were in a separate service, in a format that had just changed. Admissions distances were buried in council PDFs, a different one per borough, each with its own layout. I ended up building a spreadsheet, and then I got tired of the spreadsheet.
So I built this instead. It pulls the official figures into one place and puts them side by side, which is what I wanted and could not find.
I want to be straightforward about that. I'm not a teacher, a governor, or an education researcher. I have no qualification that makes my opinion about a school worth more than yours.
What I do have is the problem itself — I'm going through primary admissions right now — and a working knowledge of data, which is what I do for a living. That combination is enough to take published figures and present them honestly. It is not enough to tell you which school is right for your child, and this site never tries to.
Everything here is official published data: Key Stage 2 and Key Stage 4 results and school characteristics from the Department for Education, inspection outcomes from Ofsted, and admissions data from local authorities. Nothing is estimated, modelled or filled in. Where a figure is missing, the page says so rather than showing a guess.
This is an independent site. It is not affiliated with the Department for Education or with Ofsted, and nobody pays to appear on it or to rank higher.
A school is not its results. The figures here describe one year group, on a handful of days, measured in a way that suits national statistics rather than your child. A small cohort makes percentages swing wildly — in a class of thirty, one pupil is more than three points. Results say nothing at all about whether a child will be happy somewhere.
I try to build that honesty into the site rather than just say it here. Special schools and pupil referral units are never compared against a mainstream national average, because that comparison is meaningless and makes good schools look like failing ones. Where a number is unreliable, the aim is for the page to tell you before you draw a conclusion from it.
Tell me and I'll fix it. Genuinely — if a figure looks wrong, or a page gives a misleading impression of a school, I want to know. It's the fastest way this gets better.
Blog
What school performance data shows, what it doesn't, and how to read it without being misled. Written by{' '} Tudor.
No posts yet.
) : ({post.excerpt}
By Tudor {' · '}
{/* A plain