From 748ef321806fd2b2ed4712cff51fe9b9af291d8b Mon Sep 17 00:00:00 2001 From: Tudor Date: Wed, 2 Sep 2026 14:43:20 +0100 Subject: [PATCH 01/12] docs(about-blog): design for a named author, an About page and a Payload blog MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The site reads as synthetic because nobody is accountable for the numbers, no editorial judgement is visible, and the voice is institutional third person. This designs the fix: a named author (first name, photo, explicitly not an education expert), a coded /about page, and a blog backed by Payload CMS running inside the existing Next app. Also fills a hole in the SEO programme, which has eight workstreams and no E-E-A-T or authorship signal on a YMYL corpus. Records two collisions found while designing, both of which fail badly if missed: Payload's default /api route fights the existing FastAPI catch-all proxy, and withPayload() is ESM-only so next.config.js — which carries staging's noindex header — has to become next.config.mjs. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01FT1Ls4GbgLDXoQX7NAuHGT --- .../specs/2026-09-02-about-and-blog-design.md | 341 ++++++++++++++++++ 1 file changed, 341 insertions(+) create mode 100644 docs/superpowers/specs/2026-09-02-about-and-blog-design.md diff --git a/docs/superpowers/specs/2026-09-02-about-and-blog-design.md b/docs/superpowers/specs/2026-09-02-about-and-blog-design.md new file mode 100644 index 0000000..3b2a99f --- /dev/null +++ b/docs/superpowers/specs/2026-09-02-about-and-blog-design.md @@ -0,0 +1,341 @@ +# Giving schoolcompare a human author: an About page and a blog + +**Date:** 2026-09-02 +**Status:** Design — awaiting review +**Scope:** A named author for the site, an `/about` page, and a Payload-CMS-backed +blog at `/blog`. + +## Why + +The site reads as synthetic. Not because of its tone, but because of three +specific absences: + +1. **Nobody is accountable for the numbers.** There is no author, no statement + of why the site exists, and no one who can be wrong. The only human trace on + the entire site is `contact@schoolcompare.co.uk` in the footer. +2. **No visible judgement.** Every figure is presented as though it fell out of + a machine. Hundreds of editorial decisions went into this codebase — which + metrics to show, when a benchmark is invalid, what to suppress — and not one + of them is visible to a reader. `isSpecialSchool()` silently drops the + England comparison for special schools and PRUs because that comparison is + meaningless; nowhere does the site *say* so. +3. **The voice is institutional third person.** "schoolcompare brings it all + into one place." "Built for parents, governors, journalists." That is + brochure register, and it is precisely the register that machine-generated + content defaults to. + +There is a second, independent reason. The SEO programme +(`2026-08-20-seo-programme-design.md`) defines eight workstreams and none of +them address E-E-A-T or authorship. School performance data is YMYL territory; +an anonymous site republishing DfE figures has no authorship signal at all. This +work fills that hole, and the blog gives W6 (explainer content) somewhere to +live. + +### The failure mode to avoid + +The standard fix — a stock photo and "Hi, I'm Tudor, and I'm passionate about +education!" — reads as *more* synthetic than the current coldness. Manufactured +warmth is a stronger machine-tell than plain institutional voice. Everything +here has to be specific, occasionally awkward, and willing to be unflattering, +or it makes the problem worse. + +## Positioning + +The author is **Tudor**: first name only, real photograph, no surname, no +employer named. + +The credibility claim is deliberately **not** educational expertise. The About +page states plainly: *"I'm not an education expert."* Authority comes from two +things that are actually true: + +- **Experience.** A parent going through primary admissions in south-west London + right now. Google's E-E-A-T leads with Experience, and lived experience of the + thing is exactly what the DfE's own service lacks. +- **Method.** Every number's provenance is stated, so a reader can check the + site rather than trust it. + +This is more durable than borrowed expertise: it cannot be undermined by someone +noticing the author has no teaching qualification. + +**Consequence for the design.** A `Person` entity with no surname is a weak +search signal and cannot be corroborated off-site. The credibility load +therefore shifts onto the methodology being visibly rigorous. That is a design +constraint, not a caveat — it is why the About page carries a substantial +"how this is built and where it can be wrong" section rather than a short bio. + +### Voice rules + +Applied to About and every post. Recorded here so the voice does not drift. + +- First person singular. "I built", not "we provide". +- Concrete over general. "when we were looking at schools in Wandsworth" beats + any amount of stated warmth. +- State limits before someone else finds them. Every post that presents a + metric says what it does not show. +- No mission statements, no "passionate about", no invented team. +- Short sentences. The existing code comments in this repo are already written + this way; the prose should match. + +## Scope + +**In:** + +- `/about` — a coded page (not CMS-managed). +- `/blog` and `/blog/[slug]` — Payload-backed, with an index and post pages. +- Payload CMS installed into the existing Next application. +- Footer and navigation links to both. +- `Person`, `Organization`, `BlogPosting`, `BreadcrumbList` JSON-LD. +- RSS feed and sitemap integration. +- One first post, so the blog does not launch empty. + +**Out (deliberately):** + +- Rewriting existing homepage/how-it-works copy into first person. Worth doing, + but it would double the review surface of this PR. Separate change. +- In-product signed notes on school pages (the "distributed humanity" idea). + Revisit once About and the blog exist. +- Comments, newsletter, author accounts beyond one. +- A team page. There is no team. + +## Architecture + +### Topology + +Payload 3 installs **into the existing Next application** and serves `/admin` +from the same container. One image, one deploy, no new service. This is +Payload 3's native model and it makes on-demand revalidation trivial, because +the CMS hooks run in the same process as the Next cache. + +Accepted costs: the public site's image now carries Payload, so a CMS security +patch redeploys the whole site; and the image grows substantially. + +### Two collisions that must be handled + +**1. `/api` is already taken.** `app/api/[...path]/route.ts` is a catch-all that +proxies `/api/*` to FastAPI at runtime. Payload's default API route is also +`/api`. Left alone, these fight, and the failure is not clean — the catch-all +would swallow Payload's admin API calls and forward them to FastAPI. + +Payload's API route is therefore remapped: + +```ts +routes: { api: '/cms-api', admin: '/admin' } +``` + +with its route group at `app/(payload)/cms-api/[...slug]/route.ts`. The +`/cms-api` prefix must also be added to the FastAPI proxy's excluded-paths list +as a defensive second line. + +**2. `next.config.js` is CommonJS.** Payload's `withPayload()` wrapper is ESM +only. The config must become `next.config.mjs`, converting `module.exports` to +`export default` and wrapping the export. All existing content — the standalone +output, `outputFileTracingIncludes`, the staging `X-Robots-Tag` header block, +the CSP — carries over unchanged. This is mechanical but it touches the file +that controls staging's noindex, so it needs care and an explicit test. + +### Database + +Payload uses the existing `sc_database` Postgres instance, in its **own +`payload` schema**: + +```ts +db: postgresAdapter({ + pool: { connectionString: process.env.DATABASE_URL }, + schemaName: 'payload', +}) +``` + +The frontend container is already on the `backend` Docker network, so it can +reach `sc_database:5432` with no networking change. It needs a new +`DATABASE_URL` environment variable. + +Schema isolation is not cosmetic. `public` currently holds the application +tables and Airflow's metadata, and `scripts/migrate_csv_to_db.py --drop` exists +to drop and reimport. Blog content living in its own schema means no data +pipeline operation can destroy it. **Before implementation, confirm that +`--drop` is schema-scoped and cannot reach `payload`.** + +Putting CMS tables in this instance is consistent with existing practice — +Airflow already stores its metadata there. + +### Migrations + +Payload's Postgres adapter auto-pushes schema in development and requires +explicit migrations in production. Use `prodMigrations`, which runs pending +migrations during server initialisation: + +```ts +db: postgresAdapter({ /* ... */, prodMigrations: migrations }) +``` + +This is preferred over a one-shot init container (the `airflow-init` pattern) +because the app is a single long-running process and there is no ordering +problem to solve. Migration files are generated with `payload migrate:create` +and committed, so schema changes travel through the same PR and staging gate as +code. + +### Media + +Uploads go to a Docker named volume, consistent with `postgres_data`, +`typesense_data` and `airflow_logs`. + +- `staticDir` must be an **absolute** path in Payload 3: `/app/media`. +- The container runs as `nextjs` (uid 1001). The Dockerfile must + `mkdir -p /app/media && chown nextjs:nodejs /app/media` **before** the volume + is mounted, or Docker will create the mountpoint root-owned and every upload + will fail with EACCES. +- `sharp` moves from `devDependencies` to `dependencies` — Payload needs it at + runtime to generate `imageSizes`. +- The volume must be added to the backup routine alongside Postgres. A blog + post's images are not reproducible from the pipeline. + +### Rendering + +**Constraint:** CI builds the image with no database reachable. Blog pages +therefore cannot use build-time `generateStaticParams` — that would either fail +the build or bake in an empty post list. + +Instead: ISR. Post and index pages declare a `revalidate` window and render on +first request, with Payload `afterChange` / `afterDelete` hooks calling +`revalidatePath('/blog')` and `revalidatePath('/blog/' + slug)` for immediate +publication. Because Payload runs in the same process, the hook calls +`revalidatePath` from `next/cache` directly — no webhook, no shared secret. + +The ISR cache lives on container disk and is cleared by a redeploy. For a +single container serving a handful of posts this is fine. + +### Collections + +- **`posts`** — `title`, `slug`, `publishedAt`, `excerpt`, `heroImage` + (relation to `media`), `content` (Lexical rich text), `seo` group + (`metaTitle`, `metaDescription`), `_status` (drafts enabled). +- **`media`** — upload collection, `alt` required, `imageSizes` for thumbnail + and hero widths, public read access. +- **`users`** — Payload's auth collection. One account. Public creation + disabled. + +Drafts are enabled so posts can be written over several sittings and previewed +before publication. + +**Payload Blocks** are how posts embed live product components — a real trend +chart or comparison table inside a post, rendered from live data rather than +screenshotted. This is the main thing the CMS has to earn back against +file-based MDX, and it directly serves the goal: showing judgement in context. +Ship with one block (a callout/aside for "what this number doesn't tell you"); +add a live-chart block once a post needs it. + +### Security + +`/admin` is the first authenticated surface on this site. Public, hardened: + +- `PAYLOAD_SECRET` — long, random, set in the Portainer stack environment, never + committed. The same variable must exist in staging with a *different* value. +- Strong unique password on the single admin account. +- Login rate limiting via Payload's `maxLoginAttempts` / `lockTime`. +- `X-Robots-Tag: noindex, nofollow` on `/admin/*` and `/cms-api/*`, and a + `robots.ts` disallow. The admin panel must never be indexed. +- Public user creation disabled; no open registration. +- Verify the existing CSP `frame-ancestors` directive does not break the admin + panel. + +Residual risk, accepted: a future Payload authentication CVE is live against the +public internet. Mitigation is prompt patching, which the staging→prod pipeline +already supports. If this becomes uncomfortable, restricting `/admin` at the +proxy to LAN/VPN is a one-line change later. + +Staging note: staging runs the same image on `stx.`, so it gets its own admin +panel and its own database. It must have its own `PAYLOAD_SECRET` and its own +credentials — never production's. + +## Deployment changes + +- `nextjs-app/Dockerfile` — create and chown `/app/media`; ensure Payload's + admin bundle and `sharp` survive standalone output file tracing. +- `docker-compose.portainer.yml` and the staging equivalent — add + `DATABASE_URL` and `PAYLOAD_SECRET` to the `frontend` service, add a + `payload_media` volume mounted at `/app/media`, and add + `depends_on: sc_database`. +- Document both new environment variables in the compose header comment block, + which is where this stack records its configuration. + +## SEO + +- `Person` (Tudor, with photo) and `Organization` JSON-LD on `/about`. +- `BlogPosting` + `BreadcrumbList` on post pages, with `author` referencing the + same `Person`. +- Canonical URLs on `/blog` and every post. +- Posts and `/about` added to the existing sitemap (`app/sitemap.xml/route.ts` + and `app/sitemaps/[...parts]`). Post URLs come from Payload at request time. +- RSS feed at `/blog/rss.xml`. +- Footer links to both pages, under a new "About" column. + +**Navigation is deliberately left alone.** `Navigation.tsx` renders a bottom tab +bar on mobile that already carries four items (Search, Compare, Rankings, +Admissions). A fifth tab makes each one cramped at 320px, and About and Blog are +both lower-intent than any of the four. Both live in the footer; About +additionally gets a byline link from every post, which is where a reader who +cares actually asks the question. Revisit only if analytics show people hunting +for it. + +## Testing + +Unit (Jest): + +- Post rendering, including a post with no hero image and one with no excerpt. +- Slug generation and collision handling. +- JSON-LD shape for `BlogPosting` and `Person`. +- The `next.config.mjs` conversion preserves the staging `X-Robots-Tag` rule — + this guards the riskiest mechanical change in the plan. + +E2E (Playwright, `e2e/`, required by CLAUDE.md for user-facing change): + +- `/about` renders, shows the author name and photo, and is reachable from the + footer and nav. +- `/blog` lists at least one post; clicking through reaches the post. +- A post page renders title, date, body and byline. +- `/admin` responds with `noindex` and does not leak a stack trace when + unauthenticated. + +Note the known constraint: new journeys cannot be proven in PR checks, because +the staging E2E gate runs post-merge. + +## Risks + +| Risk | Mitigation | +|---|---| +| `next.config.mjs` conversion silently drops the staging noindex header, making staging a crawlable duplicate | Unit test asserting the header rule; verify on staging before promotion | +| Payload API route collides with the FastAPI `/api` proxy | Remap to `/cms-api`; add to the proxy's exclusion list | +| Media volume mounts root-owned; all uploads fail with EACCES | `mkdir`+`chown` in the Dockerfile before the mount; test an upload on staging | +| Build fails or bakes empty content because CI has no DB | No build-time DB access; ISR only | +| A pipeline `--drop` destroys blog content | Separate `payload` schema; verify `--drop` blast radius before building | +| Media volume not backed up; images unrecoverable | Add `payload_media` to the backup routine | +| Payload auth CVE exposed publicly | Prompt patching; proxy restriction available as a fallback | +| Blog launches empty or goes stale | Ship with one post; cadence is explicitly "a few times a year", so no cadence is promised anywhere on the page — no dates implying a schedule | + +## Sequence + +Each step is independently reviewable and mergeable. + +1. **Payload foundation** — install, `next.config.mjs` conversion, `payload` + schema, `/cms-api` remap, `users` collection, `/admin` hardening, compose and + Dockerfile changes. No public-facing change yet. Verify on staging that the + site is unchanged and `/admin` works. +2. **`/about`** — coded page, photo, `Person`/`Organization` JSON-LD, footer and + nav links, e2e journey. Independently valuable and does not depend on the + blog. +3. **Blog** — `posts` and `media` collections, `/blog` index and post pages, ISR + plus revalidation hooks, RSS, sitemap, structured data, e2e journeys. +4. **First post** — written in the admin panel, published through the normal + flow, proving the whole path end to end. + +Step 1 carries all the infrastructure risk and none of the visible benefit, so +it should be verified on staging carefully before step 2 starts. + +## Dependencies on Tudor + +- **A photograph.** Blocks step 2. Nothing else in the plan is blocked by it. +- **The first post's subject.** Blocks step 4 only. Suggested: what school + performance data cannot tell you — it demonstrates judgement, is genuinely + useful, and is the kind of thing an anonymous or machine-written site will not + publish. +- **Confirmation** that `scripts/migrate_csv_to_db.py --drop` is schema-scoped. From 74e5fffc10071637b85a5eb5ff3fbb4f1f7deeb2 Mon Sep 17 00:00:00 2001 From: Tudor Date: Wed, 2 Sep 2026 15:59:44 +0100 Subject: [PATCH 02/12] docs(about-blog): implementation plan for the About page and Payload blog MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Nine tasks, each ending in an independently testable deliverable. Two structural findings that the spec did not anticipate, both recorded in the plan. Payload's admin panel ships its own root layout rendering html/body, and Next allows multiple root layouts only when no app/layout.tsx exists — so every existing route moves into an app/(frontend) route group first, on its own, with the full suite as the gate. Route groups are invisible to routing, so no public URL changes. The second finding corrects the spec: adding /cms-api to the FastAPI proxy's exclusion list would be dead code, because that catch-all only ever matches /api/*. The route remap alone is sufficient. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_017YmbBhr8s7GusjDE12hrZM --- .../plans/2026-09-02-about-and-blog.md | 2278 +++++++++++++++++ 1 file changed, 2278 insertions(+) create mode 100644 docs/superpowers/plans/2026-09-02-about-and-blog.md diff --git a/docs/superpowers/plans/2026-09-02-about-and-blog.md b/docs/superpowers/plans/2026-09-02-about-and-blog.md new file mode 100644 index 0000000..ec3249a --- /dev/null +++ b/docs/superpowers/plans/2026-09-02-about-and-blog.md @@ -0,0 +1,2278 @@ +# 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`, `robots.ts`, `opengraph-image.tsx`, +`icon.png`, `apple-icon.png`, `rankings/`, `admissions/`, `compare/`, +`schools/`, `school/`, `api/`, `sitemaps/`, `sitemap.xml/`. + +**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)//page`. + +- [ ] **Step 1: Record the baseline** + +```bash +cd nextjs-app && npm test 2>&1 | tail -5 +``` + +Write down the passing test count. It must be identical at Step 5. + +- [ ] **Step 2: Move every route into the group** + +```bash +cd nextjs-app/app && mkdir -p "(frontend)" +git mv layout.tsx page.tsx globals.css robots.ts opengraph-image.tsx \ + icon.png apple-icon.png \ + rankings admissions compare schools school api sitemaps sitemap.xml \ + "(frontend)/" +``` + +Verify nothing is left behind — `app/` should now contain only `(frontend)`: + +```bash +cd /Users/tudor/projects/school_compare/nextjs-app && ls app +``` + +If anything else appears, move it too. `git status --short` is the +authoritative check: untracked files do not show in `git diff --stat`. + +- [ ] **Step 3: Update the four test imports** + +In `__tests__/app/metadata.test.ts`, change lines 1-4 to: + +```ts +import { metadata as homeMetadata } from '@/app/(frontend)/page'; +import { metadata as rankingsMetadata } from '@/app/(frontend)/rankings/page'; +import { metadata as admissionsMetadata } from '@/app/(frontend)/admissions/page'; +import { generateMetadata as compareMetadata } from '@/app/(frontend)/compare/page'; +``` + +In `__tests__/app/placeMetadata.test.ts`, line 1: + +```ts +import { generateMetadata as placeMeta } from '@/app/(frontend)/schools/[place]/page'; +``` + +In `__tests__/api/proxyDenylist.test.ts`, line 11: + +```ts +import { GET } from '@/app/(frontend)/api/[...path]/route'; +``` + +- [ ] **Step 4: Find any other references to the old paths** + +```bash +cd /Users/tudor/projects/school_compare && grep -rn "app/layout\|app/page\|@/app/" \ + --include=*.ts --include=*.tsx --include=*.js --include=*.mjs \ + nextjs-app --exclude-dir=node_modules --exclude-dir=.next | grep -v "app/(frontend)" +``` + +Expected: no output. Fix anything that appears. + +- [ ] **Step 5: Verify tests, types and build** + +```bash +cd nextjs-app && npm test && npm run typecheck && npm run build +``` + +Expected: the same passing count as Step 1, and a successful build. In the +build's route table, confirm the routes are still listed as `/`, `/rankings`, +`/compare`, `/admissions`, `/school/[slug]` — **not** `/(frontend)/...`. If the +group name appears in a URL, the directory was named wrongly (it must include +the parentheses). + +- [ ] **Step 6: Commit** + +```bash +git add -A nextjs-app +git commit -m "refactor(app): move site routes into a (frontend) route group + +Payload's admin panel ships its own root layout rendering html/body. +Next allows multiple root layouts only when no app/layout.tsx exists, so +the site's routes move into their own group. Route groups are invisible +to routing: every public URL is unchanged." +``` + +--- + +### Task 3: Install Payload and bring up `/admin` + +**Files:** +- Modify: `nextjs-app/package.json` +- Modify: `nextjs-app/tsconfig.json` +- Modify: `nextjs-app/next.config.mjs` +- Create: `nextjs-app/payload.config.ts` +- Create: `nextjs-app/collections/Users.ts` +- Create: `nextjs-app/lib/payload.ts` +- Create: `nextjs-app/app/(payload)/**` (generated) +- Create: `nextjs-app/__tests__/payload/config.test.ts` + +**Interfaces:** +- Consumes: Task 2's absent root layout. +- Produces: `payload.config.ts` default-exporting the built config; + `getCachedPayload(): Promise` from `lib/payload.ts`, used by Tasks 7 + and 8 to query content. + +- [ ] **Step 1: Install the packages** + +```bash +cd nextjs-app +npm install payload @payloadcms/db-postgres @payloadcms/next @payloadcms/richtext-lexical graphql +npm install sharp +npm uninstall --save-dev sharp +``` + +`sharp` moves from `devDependencies` to `dependencies`: Payload needs it at +runtime to generate `imageSizes`, and the Docker runner stage installs +production dependencies only. + +- [ ] **Step 2: Add the `@payload-config` path alias** + +In `nextjs-app/tsconfig.json`, extend `compilerOptions.paths` (currently +`{"@/*": ["./*"]}`) to: + +```json +"paths": { + "@/*": ["./*"], + "@payload-config": ["./payload.config.ts"] +} +``` + +- [ ] **Step 3: Write the failing config test** + +Create `nextjs-app/__tests__/payload/config.test.ts`: + +```ts +/** + * @jest-environment node + */ +import config from '@/payload.config'; + +describe('payload config', () => { + it('serves its API from /cms-api, not /api', async () => { + // app/(frontend)/api/[...path]/route.ts is a catch-all proxying /api/* + // to FastAPI. Payload's default /api would be swallowed by it, and the + // failure is silent — admin calls would be forwarded to the backend. + const resolved = await config; + expect(resolved.routes.api).toBe('/cms-api'); + }); + + it('serves the admin panel from /admin', async () => { + const resolved = await config; + expect(resolved.routes.admin).toBe('/admin'); + }); + + it('authenticates against the users collection', async () => { + const resolved = await config; + expect(resolved.admin.user).toBe('users'); + }); +}); +``` + +- [ ] **Step 4: Run it to verify it fails** + +```bash +cd nextjs-app && npm test -- __tests__/payload/config.test.ts +``` + +Expected: FAIL — cannot resolve `@/payload.config`. + +- [ ] **Step 5: Write the Users collection** + +Create `nextjs-app/collections/Users.ts`: + +```ts +import type { CollectionConfig } from 'payload'; + +/** + * The site's only authenticated surface. There is one account and no + * registration: `create` is closed to everyone, so the first user is seeded + * with `payload create-first-user` and no one can add another through the API. + */ +export const Users: CollectionConfig = { + slug: 'users', + auth: { + // Slows credential stuffing against a panel that is on the public + // internet. Five attempts, then a ten-minute lock. + maxLoginAttempts: 5, + lockTime: 10 * 60 * 1000, + }, + access: { + create: () => false, + read: ({ req }) => Boolean(req.user), + update: ({ req }) => Boolean(req.user), + delete: () => false, + }, + admin: { useAsTitle: 'email' }, + fields: [ + { + name: 'displayName', + type: 'text', + required: true, + // Rendered as the byline on every post. First name only — see the + // author identity constraint at the top of this plan. + defaultValue: 'Tudor', + }, + ], +}; +``` + +- [ ] **Step 6: Write the Payload config** + +Create `nextjs-app/payload.config.ts`: + +```ts +import path from 'path'; +import { fileURLToPath } from 'url'; +import { buildConfig } from 'payload'; +import { postgresAdapter } from '@payloadcms/db-postgres'; +import { lexicalEditor } from '@payloadcms/richtext-lexical'; +import sharp from 'sharp'; +import { Users } from '@/collections/Users'; + +const filename = fileURLToPath(import.meta.url); +const dirname = path.dirname(filename); + +export default buildConfig({ + admin: { user: Users.slug }, + // /api belongs to the FastAPI proxy. See the config test for why this + // must never move back. + routes: { api: '/cms-api', admin: '/admin' }, + collections: [Users], + editor: lexicalEditor(), + secret: process.env.PAYLOAD_SECRET || '', + typescript: { outputFile: path.resolve(dirname, 'payload-types.ts') }, + db: postgresAdapter({ + pool: { connectionString: process.env.DATABASE_URL }, + // Its own schema, so no pipeline operation on `public` can reach blog + // content. scripts/migrate_csv_to_db.py --drop lives in that blast radius. + schemaName: 'payload', + }), + sharp, +}); +``` + +- [ ] **Step 7: Generate the Payload route files** + +```bash +cd nextjs-app && npx create-payload-app@latest --no-deps +``` + +Choose the options for adding to an existing Next.js app. This generates +`app/(payload)/` — the admin panel routes, the `/cms-api` route handlers, and +Payload's own root layout. **Do not hand-write these files**; they are +version-coupled to the installed Payload release and are regenerated on upgrade. + +Then confirm the generated API directory matches the remapped route: + +```bash +cd nextjs-app && ls "app/(payload)" +``` + +If the generated directory is `app/(payload)/api`, rename it to match +`routes.api`: + +```bash +cd nextjs-app && git mv "app/(payload)/api" "app/(payload)/cms-api" +``` + +If the generator overwrote `payload.config.ts`, restore the version from +Step 6 — the `routes` and `schemaName` keys are the whole point and the +generator does not write them. + +- [ ] **Step 8: Wrap the Next config** + +In `nextjs-app/next.config.mjs`, add at the top: + +```js +import { withPayload } from '@payloadcms/next/withPayload'; +``` + +and change the final line from `export default nextConfig;` to: + +```js +export default withPayload(nextConfig); +``` + +- [ ] **Step 9: Add the cached Payload accessor** + +Create `nextjs-app/lib/payload.ts`: + +```ts +import { getPayload } from 'payload'; +import config from '@payload-config'; +import type { Payload } from 'payload'; + +/** + * One Payload instance per process. getPayload() is itself memoised by + * Payload, but routing every caller through here keeps the config import in + * a single place and gives page code one name to mock in tests. + */ +export function getCachedPayload(): Promise { + return getPayload({ config }); +} +``` + +- [ ] **Step 10: Run the config test** + +```bash +cd nextjs-app && npm test -- __tests__/payload/config.test.ts +``` + +Expected: PASS, 3 tests. + +- [ ] **Step 11: Verify types and build** + +```bash +cd nextjs-app && npm test && npm run typecheck && npm run build +``` + +Expected: all green. The build must succeed **without** a database — nothing +added so far queries Payload at build time. If the build tries to connect, +something imported `getCachedPayload()` at module scope; move it inside the +request handler. + +- [ ] **Step 12: Commit** + +```bash +git add -A nextjs-app +git commit -m "feat(cms): install Payload and serve the admin panel + +Payload runs inside the Next app against the existing Postgres, in its +own 'payload' schema so no pipeline operation on public can reach blog +content. Its API is remapped to /cms-api because /api is the FastAPI +proxy's catch-all." +``` + +--- + +### Task 4: Harden the admin surface + +`/admin` is the first authenticated surface on this site. It must never be +indexed and must not be reachable through search results. + +**Files:** +- Modify: `nextjs-app/app/(frontend)/robots.ts` +- Modify: `nextjs-app/next.config.mjs` +- Modify: `nextjs-app/__tests__/app/nextConfig.test.ts` +- Create: `nextjs-app/__tests__/app/robots.test.ts` + +**Interfaces:** +- Consumes: Task 3's `/admin` and `/cms-api` routes. +- Produces: nothing consumed by later tasks. + +- [ ] **Step 1: Write the failing tests** + +Create `nextjs-app/__tests__/app/robots.test.ts`: + +```ts +import robots from '@/app/(frontend)/robots'; + +describe('robots.txt', () => { + it('disallows the admin panel and the CMS API', () => { + const rules = robots().rules; + const rule = Array.isArray(rules) ? rules[0] : rules; + expect(rule.disallow).toEqual( + expect.arrayContaining(['/api/', '/_next/', '/admin/', '/cms-api/']), + ); + }); +}); +``` + +Append to `nextjs-app/__tests__/app/nextConfig.test.ts`: + +```ts +describe('admin surface', () => { + it('serves noindex on the admin panel and the CMS API', async () => { + const headers = await nextConfig.headers(); + for (const source of ['/admin/:path*', '/cms-api/:path*']) { + const rule = headers.find((entry) => entry.source === source); + expect(rule).toBeDefined(); + expect(rule.headers).toContainEqual({ + key: 'X-Robots-Tag', + value: 'noindex, nofollow', + }); + } + }); +}); +``` + +- [ ] **Step 2: Run them to verify they fail** + +```bash +cd nextjs-app && npm test -- __tests__/app/robots.test.ts __tests__/app/nextConfig.test.ts +``` + +Expected: FAIL — the disallow list lacks the new entries, and neither header +rule exists. + +- [ ] **Step 3: Add the robots disallow entries** + +In `nextjs-app/app/(frontend)/robots.ts`, change the `disallow` array to: + +```ts +disallow: ['/api/', '/_next/', '/admin/', '/cms-api/'], +``` + +- [ ] **Step 4: Add the noindex headers** + +In `nextjs-app/next.config.mjs`, add these two entries to the array returned by +`headers()`, immediately after the staging-host rule: + +```js +{ + /* + * The admin panel and the CMS API must never be indexed. robots.txt + * disallows them too, but a Disallow only blocks crawling — a URL found + * from an external link can still be indexed without ever being fetched. + * This header is what actually keeps them out. + */ + source: '/admin/:path*', + headers: [{ key: 'X-Robots-Tag', value: 'noindex, nofollow' }], +}, +{ + source: '/cms-api/:path*', + headers: [{ key: 'X-Robots-Tag', value: 'noindex, nofollow' }], +}, +``` + +- [ ] **Step 5: Run the tests to verify they pass** + +```bash +cd nextjs-app && npm test -- __tests__/app/robots.test.ts __tests__/app/nextConfig.test.ts +``` + +Expected: PASS. + +- [ ] **Step 6: Confirm the CSP does not block the admin panel** + +The existing global CSP sets `frame-ancestors 'self' https://analytics.schoolcompare.co.uk`. +`frame-ancestors` restricts who may embed this site; it does not restrict +scripts, styles or fetches, so it cannot break the admin panel. Confirm no +other CSP directive was added, then move on: + +```bash +cd nextjs-app && grep -n "Content-Security-Policy" -A2 next.config.mjs +``` + +Expected: only the `frame-ancestors` directive. + +- [ ] **Step 7: Full verification and commit** + +```bash +cd nextjs-app && npm test && npm run typecheck && npm run build +git add -A nextjs-app +git commit -m "feat(cms): keep the admin panel out of the index + +X-Robots-Tag rather than robots.txt alone: a Disallow blocks crawling, +not indexing, and a disallowed URL found from an external link can be +indexed without ever being fetched." +``` + +--- + +### Task 5: Make the stack deployable + +**Files:** +- Modify: `nextjs-app/Dockerfile` +- Modify: `docker-compose.portainer.yml` +- Modify: `docker-compose.portainer.staging.yml` +- Modify: `nextjs-app/payload.config.ts` +- Create: `nextjs-app/migrations/` (generated) + +**Interfaces:** +- Consumes: Task 3's `payload.config.ts`. +- Produces: a container that runs migrations on boot and can write uploads to + `/app/media`. + +- [ ] **Step 1: Generate the initial migration** + +With a local Postgres reachable and `DATABASE_URL` / `PAYLOAD_SECRET` exported: + +```bash +cd nextjs-app && npx payload migrate:create initial +``` + +This writes `migrations/_initial.ts` and `migrations/index.ts`. +Both are committed — schema changes travel through the same PR and staging gate +as code. + +- [ ] **Step 2: Run migrations on boot in production** + +In `nextjs-app/payload.config.ts`, add the import and the adapter key: + +```ts +import { migrations } from '@/migrations'; +``` + +```ts + db: postgresAdapter({ + pool: { connectionString: process.env.DATABASE_URL }, + schemaName: 'payload', + // Runs pending migrations during server init. Preferred over a one-shot + // init container (the airflow-init pattern) because this is a single + // long-running process with no ordering problem to solve. + prodMigrations: migrations, + }), +``` + +- [ ] **Step 3: Create the media directory in the image** + +In `nextjs-app/Dockerfile`, in the **runner** stage, immediately after the +`COPY --from=builder /app/assets ./assets` line and **before** +`RUN chown -R nextjs:nodejs /app`, add: + +```dockerfile +# Payload writes uploads here, and the compose file mounts a named volume +# over it. The directory must exist and be owned by the runtime user BEFORE +# the mount: Docker seeds a fresh named volume from the image path, so a +# missing or root-owned directory here makes every upload fail with EACCES — +# at runtime, long after the build passed. +RUN mkdir -p /app/media +``` + +`RUN chown -R nextjs:nodejs /app` already follows and covers it. + +- [ ] **Step 4: Wire the frontend service in production compose** + +In `docker-compose.portainer.yml`, under `services.frontend`, add to +`environment`: + +```yaml + - DATABASE_URL=postgresql://${DB_USERNAME}:${DB_PASSWORD}@sc_database:5432/${DB_DATABASE_NAME} + - PAYLOAD_SECRET=${PAYLOAD_SECRET:?set PAYLOAD_SECRET in the Portainer stack environment} +``` + +Add to the service: + +```yaml + volumes: + - payload_media:/app/media +``` + +Add `sc_database` to its `depends_on`: + +```yaml + depends_on: + backend: + condition: service_healthy + sc_database: + condition: service_healthy +``` + +Add to the top-level `volumes:` block: + +```yaml + payload_media: +``` + +Add to the header comment block, alongside the other documented variables: + +``` +# PAYLOAD_SECRET — Payload CMS encryption secret. REQUIRED: long and +# random. Changing it invalidates all admin sessions. +# Staging MUST use a different value from production. +``` + +The `:?` form is deliberate and matches `AIRFLOW_ADMIN_PASSWORD` above it: the +container refuses to start rather than booting with an empty secret. + +- [ ] **Step 5: Apply the same changes to staging** + +Repeat Step 4 in `docker-compose.portainer.staging.yml`, adapting service and +host names to that file's conventions. Staging gets its **own** +`PAYLOAD_SECRET` and its own admin credentials — never production's. + +- [ ] **Step 6: Verify the build** + +```bash +cd nextjs-app && npm run typecheck && npm run build +docker build -t sc-frontend-test nextjs-app +``` + +Expected: both succeed. The Docker build proves the `mkdir`/`chown` ordering +and that `sharp` resolves as a production dependency. + +- [ ] **Step 7: Commit** + +```bash +git add -A nextjs-app docker-compose.portainer.yml docker-compose.portainer.staging.yml +git commit -m "build(cms): make the Payload-enabled image deployable + +Migrations run on server init via prodMigrations. Uploads go to a named +volume at /app/media, created and chowned in the image before the mount +so Docker does not seed it root-owned and fail every upload at runtime." +``` + +- [ ] **Step 8: Note the operational follow-ups for the human** + +Record these in the PR description — they are not code: +- Set `PAYLOAD_SECRET` in both Portainer stacks (different values). +- Add the `payload_media` volume to the backup routine. Post images are not + reproducible from the pipeline. +- After first deploy, run `npx payload create-first-user` against the + container to seed the single admin account. +- Confirm `scripts/migrate_csv_to_db.py --drop` is schema-scoped and cannot + reach the `payload` schema. + +--- + +### Task 6: The About page + +**Files:** +- Create: `nextjs-app/app/(frontend)/about/page.tsx` +- Create: `nextjs-app/app/(frontend)/about/About.module.css` +- Create: `nextjs-app/lib/jsonld.ts` +- Create: `nextjs-app/__tests__/app/aboutMetadata.test.ts` +- Add: `nextjs-app/public/brand/tudor.jpg` (supplied by the human) +- Modify: `nextjs-app/components/Footer.tsx` +- Modify: `e2e/tests/journeys.spec.ts` + +**Interfaces:** +- Consumes: `absoluteUrl` from `lib/site.ts`. +- Produces: `personJsonLd()` and `organizationJsonLd()` from `lib/jsonld.ts`, + reused by Task 8's `BlogPosting.author`. + +- [ ] **Step 1: Write the failing tests** + +Create `nextjs-app/__tests__/app/aboutMetadata.test.ts`: + +```ts +import { metadata } from '@/app/(frontend)/about/page'; +import { personJsonLd, organizationJsonLd } from '@/lib/jsonld'; + +describe('/about metadata', () => { + it('canonicalises to the bare path', () => { + expect(metadata.alternates?.canonical) + .toBe('https://www.schoolcompare.co.uk/about'); + }); +}); + +describe('author structured data', () => { + it('describes a Person with a first name and a photo', () => { + const person = personJsonLd(); + expect(person['@type']).toBe('Person'); + expect(person.name).toBe('Tudor'); + expect(person.image).toBe('https://www.schoolcompare.co.uk/brand/tudor.jpg'); + expect(person.url).toBe('https://www.schoolcompare.co.uk/about'); + }); + + it('never publishes a surname', () => { + // Author identity constraint: first name only, no employer. + expect(JSON.stringify(personJsonLd())).not.toMatch(/familyName|Sitaru/i); + }); + + it('describes the site as an Organization the Person authors for', () => { + const org = organizationJsonLd(); + expect(org['@type']).toBe('Organization'); + expect(org.name).toBe('schoolcompare'); + expect(org.url).toBe('https://www.schoolcompare.co.uk'); + }); +}); +``` + +- [ ] **Step 2: Run to verify failure** + +```bash +cd nextjs-app && npm test -- __tests__/app/aboutMetadata.test.ts +``` + +Expected: FAIL — neither module exists. + +- [ ] **Step 3: Write the JSON-LD builders** + +Create `nextjs-app/lib/jsonld.ts`: + +```ts +import { SITE_URL, absoluteUrl } from '@/lib/site'; + +/** + * The site's author entity. First name only, by choice: see the About page. + * Everything that needs an author — the About page, every post byline — + * references this one shape so the entity stays consistent for search. + */ +export function personJsonLd() { + return { + '@type': 'Person', + '@id': `${SITE_URL}/about#tudor`, + name: 'Tudor', + url: absoluteUrl('/about'), + image: absoluteUrl('/brand/tudor.jpg'), + description: + 'Parent in south-west London who built schoolcompare while looking for a primary school.', + } as const; +} + +export function organizationJsonLd() { + return { + '@type': 'Organization', + '@id': `${SITE_URL}#organization`, + name: 'schoolcompare', + url: SITE_URL, + logo: absoluteUrl('/icon-512.png'), + } as const; +} +``` + +- [ ] **Step 4: Write the About page** + +Create `nextjs-app/app/(frontend)/about/page.tsx`. The copy below is the +deliverable — write it as given. It follows the voice rules and the "not an +education expert" constraint. + +```tsx +import type { Metadata } from 'next'; +import Image from 'next/image'; +import { absoluteUrl } from '@/lib/site'; +import { personJsonLd, organizationJsonLd } from '@/lib/jsonld'; +import styles from './About.module.css'; + +export const metadata: Metadata = { + title: 'About', + description: + 'Who builds schoolcompare, why it exists, and where its numbers come from.', + alternates: { canonical: absoluteUrl('/about') }, +}; + +export default function AboutPage() { + const jsonLd = { + '@context': 'https://schema.org', + '@graph': [personJsonLd(), organizationJsonLd()], + }; + + return ( +
+