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 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017YmbBhr8s7GusjDE12hrZM
2279 lines
69 KiB
Markdown
2279 lines
69 KiB
Markdown
# 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 `<html>` and `<body>`. Payload's
|
||
admin panel ships its own root layout that also renders `<html>`/`<body>`. 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 `<html>` 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)/<route>/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<Payload>` 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<Payload> {
|
||
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/<timestamp>_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 (
|
||
<div className={styles.page}>
|
||
<script
|
||
type="application/ld+json"
|
||
dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd) }}
|
||
/>
|
||
|
||
<header className={styles.header}>
|
||
<Image
|
||
src="/brand/tudor.jpg"
|
||
alt="Tudor, who builds schoolcompare"
|
||
width={96}
|
||
height={96}
|
||
className={styles.portrait}
|
||
priority
|
||
/>
|
||
<div>
|
||
<p className={styles.kicker}>Who's behind this</p>
|
||
<h1 className={styles.heading}>I'm Tudor. I built this site.</h1>
|
||
</div>
|
||
</header>
|
||
|
||
<div className={styles.prose}>
|
||
<p className={styles.lede}>
|
||
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.
|
||
</p>
|
||
|
||
<p>
|
||
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.
|
||
</p>
|
||
|
||
<p>
|
||
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.
|
||
</p>
|
||
|
||
<h2 className={styles.subheading}>I'm not an education expert</h2>
|
||
|
||
<p>
|
||
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.
|
||
</p>
|
||
|
||
<p>
|
||
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.
|
||
</p>
|
||
|
||
<h2 className={styles.subheading}>Where the numbers come from</h2>
|
||
|
||
<p>
|
||
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.
|
||
</p>
|
||
|
||
<p>
|
||
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.
|
||
</p>
|
||
|
||
<h2 className={styles.subheading}>What the data can't tell you</h2>
|
||
|
||
<p>
|
||
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.
|
||
</p>
|
||
|
||
<p>
|
||
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.
|
||
</p>
|
||
|
||
<h2 className={styles.subheading}>If something's wrong</h2>
|
||
|
||
<p>
|
||
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.
|
||
</p>
|
||
|
||
<p>
|
||
<a href="mailto:contact@schoolcompare.co.uk" className={styles.link}>
|
||
contact@schoolcompare.co.uk
|
||
</a>
|
||
</p>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 5: Write the stylesheet**
|
||
|
||
Create `nextjs-app/app/(frontend)/about/About.module.css`. Tokens only — no
|
||
hex values. Match the measure and rhythm of the existing editorial card in
|
||
`HomeView.module.css`.
|
||
|
||
```css
|
||
.page {
|
||
max-width: 42rem;
|
||
margin: 0 auto;
|
||
padding: 2.5rem 1.25rem 4rem;
|
||
}
|
||
|
||
.header {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 1.25rem;
|
||
margin-bottom: 2rem;
|
||
}
|
||
|
||
.portrait {
|
||
border-radius: 50%;
|
||
border: 2px solid var(--border);
|
||
object-fit: cover;
|
||
flex-shrink: 0;
|
||
}
|
||
|
||
.kicker {
|
||
font-family: var(--font-ui);
|
||
font-size: 0.75rem;
|
||
font-weight: 600;
|
||
text-transform: uppercase;
|
||
letter-spacing: 0.06em;
|
||
color: var(--brand);
|
||
margin: 0 0 0.35rem;
|
||
}
|
||
|
||
.heading {
|
||
font-family: var(--font-display);
|
||
font-size: clamp(1.5rem, 4vw, 2rem);
|
||
font-weight: 700;
|
||
line-height: 1.2;
|
||
color: var(--text-primary);
|
||
margin: 0;
|
||
}
|
||
|
||
.subheading {
|
||
font-family: var(--font-display);
|
||
font-size: 1.15rem;
|
||
font-weight: 600;
|
||
color: var(--text-primary);
|
||
margin: 2.25rem 0 0.75rem;
|
||
}
|
||
|
||
.prose p {
|
||
font-family: var(--font-ui);
|
||
font-size: 1rem;
|
||
line-height: 1.7;
|
||
color: var(--text-secondary);
|
||
margin: 0 0 1.1rem;
|
||
}
|
||
|
||
.lede {
|
||
font-size: 1.125rem !important;
|
||
color: var(--text-primary) !important;
|
||
}
|
||
|
||
.link {
|
||
color: var(--brand);
|
||
font-weight: 600;
|
||
}
|
||
|
||
.link:hover { color: var(--brand-strong); }
|
||
|
||
@media (max-width: 480px) {
|
||
.header { flex-direction: column; align-items: flex-start; gap: 1rem; }
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 6: Add the photograph**
|
||
|
||
Place the supplied portrait at `nextjs-app/public/brand/tudor.jpg`, square,
|
||
at least 384×384 so the 96px render stays sharp on a 2× display.
|
||
|
||
If the photo has not been supplied yet, **stop and ask** — do not ship a
|
||
placeholder or a stock image. A stock portrait is worse than no About page.
|
||
|
||
- [ ] **Step 7: Link it from the footer**
|
||
|
||
In `nextjs-app/components/Footer.tsx`, add a fourth `section` after the
|
||
Resources column:
|
||
|
||
```tsx
|
||
<div className={styles.section}>
|
||
<h4 className={styles.sectionTitle}>About</h4>
|
||
<ul className={styles.links}>
|
||
<li><a href="/about" className={styles.link}>Who's behind this</a></li>
|
||
<li><a href="/blog" className={styles.link}>Blog</a></li>
|
||
</ul>
|
||
</div>
|
||
```
|
||
|
||
The `/blog` link 404s until Task 8. That is acceptable within a branch that
|
||
ships as one PR; if these tasks are split across PRs, add the `/blog` entry in
|
||
Task 8 instead.
|
||
|
||
Check `Footer.module.css` — if `.content` uses a fixed column count rather
|
||
than `auto-fit`, widen it to accommodate four columns and verify at 375px.
|
||
|
||
- [ ] **Step 8: Run the tests**
|
||
|
||
```bash
|
||
cd nextjs-app && npm test -- __tests__/app/aboutMetadata.test.ts
|
||
```
|
||
|
||
Expected: PASS, 4 tests.
|
||
|
||
- [ ] **Step 9: Add the e2e journey**
|
||
|
||
Append to `e2e/tests/journeys.spec.ts`:
|
||
|
||
```ts
|
||
test('the about page names a human author and is reachable from the footer', async ({ page }) => {
|
||
await page.goto('/');
|
||
const aboutLink = page.locator('footer a[href="/about"]');
|
||
await expect(aboutLink).toBeVisible();
|
||
await aboutLink.click();
|
||
await page.waitForURL(/\/about$/);
|
||
|
||
// The whole point of the page: a named person and a face.
|
||
await expect(page.getByRole('heading', { level: 1 })).toContainText('Tudor');
|
||
await expect(page.locator('img[alt*="Tudor"]')).toBeVisible();
|
||
|
||
// The honesty claim is load-bearing, not decorative.
|
||
await expect(page.getByText(/not an education expert/i)).toBeVisible();
|
||
|
||
const jsonLd = await page.locator('script[type="application/ld+json"]').first().textContent();
|
||
expect(jsonLd).toContain('"Person"');
|
||
});
|
||
```
|
||
|
||
- [ ] **Step 10: Verify and commit**
|
||
|
||
```bash
|
||
cd nextjs-app && npm test && npm run typecheck && npm run build
|
||
git add -A nextjs-app e2e
|
||
git commit -m "feat(about): give the site a named author
|
||
|
||
The site had no author, no statement of why it exists and nobody
|
||
accountable for its numbers, which is most of why it reads as machine
|
||
generated. States plainly that the author is not an education expert:
|
||
the credibility claim is lived experience and stated provenance, which
|
||
is true and cannot be undermined."
|
||
```
|
||
|
||
---
|
||
|
||
### Task 7: Posts and Media collections
|
||
|
||
**Files:**
|
||
- Create: `nextjs-app/collections/Posts.ts`
|
||
- Create: `nextjs-app/collections/Media.ts`
|
||
- Create: `nextjs-app/blocks/Callout.ts`
|
||
- Modify: `nextjs-app/payload.config.ts`
|
||
- Create: `nextjs-app/__tests__/payload/collections.test.ts`
|
||
- Create: `nextjs-app/migrations/<timestamp>_posts_media.ts` (generated)
|
||
|
||
**Interfaces:**
|
||
- Consumes: Task 3's config, Task 5's `/app/media` volume.
|
||
- Produces: the `posts` collection (fields `title`, `slug`, `publishedAt`,
|
||
`excerpt`, `heroImage`, `content`, `_status`) and the `media` collection,
|
||
both queried by Task 8.
|
||
|
||
- [ ] **Step 1: Write the failing tests**
|
||
|
||
Create `nextjs-app/__tests__/payload/collections.test.ts`:
|
||
|
||
```ts
|
||
/**
|
||
* @jest-environment node
|
||
*/
|
||
import config from '@/payload.config';
|
||
|
||
async function collection(slug: string) {
|
||
const resolved = await config;
|
||
return resolved.collections.find((entry) => entry.slug === slug);
|
||
}
|
||
|
||
describe('posts collection', () => {
|
||
it('exists and supports drafts', async () => {
|
||
const posts = await collection('posts');
|
||
expect(posts).toBeDefined();
|
||
expect(posts.versions.drafts).toBeTruthy();
|
||
});
|
||
|
||
it('has a unique, indexed slug for stable URLs', async () => {
|
||
const posts = await collection('posts');
|
||
const slug = posts.fields.find((field) => field.name === 'slug');
|
||
expect(slug.unique).toBe(true);
|
||
expect(slug.index).toBe(true);
|
||
});
|
||
|
||
it('is publicly readable', async () => {
|
||
const posts = await collection('posts');
|
||
expect(posts.access.read({ req: {} })).toBe(true);
|
||
});
|
||
});
|
||
|
||
describe('media collection', () => {
|
||
it('writes uploads to the mounted volume', async () => {
|
||
const media = await collection('media');
|
||
expect(media.upload.staticDir).toBe('/app/media');
|
||
});
|
||
|
||
it('requires alt text on every upload', async () => {
|
||
const media = await collection('media');
|
||
const alt = media.fields.find((field) => field.name === 'alt');
|
||
expect(alt.required).toBe(true);
|
||
});
|
||
});
|
||
```
|
||
|
||
- [ ] **Step 2: Run to verify failure**
|
||
|
||
```bash
|
||
cd nextjs-app && npm test -- __tests__/payload/collections.test.ts
|
||
```
|
||
|
||
Expected: FAIL — neither collection is registered.
|
||
|
||
- [ ] **Step 3: Write the Media collection**
|
||
|
||
Create `nextjs-app/collections/Media.ts`:
|
||
|
||
```ts
|
||
import type { CollectionConfig } from 'payload';
|
||
|
||
/**
|
||
* Uploads land on a Docker named volume mounted at /app/media. The path is
|
||
* absolute because Payload 3 requires it, and it must match the mount in
|
||
* docker-compose.portainer.yml exactly.
|
||
*/
|
||
export const Media: CollectionConfig = {
|
||
slug: 'media',
|
||
access: { read: () => true },
|
||
upload: {
|
||
staticDir: '/app/media',
|
||
mimeTypes: ['image/*'],
|
||
imageSizes: [
|
||
{ name: 'thumbnail', width: 400 },
|
||
{ name: 'hero', width: 1200 },
|
||
],
|
||
adminThumbnail: 'thumbnail',
|
||
},
|
||
fields: [
|
||
{
|
||
name: 'alt',
|
||
type: 'text',
|
||
required: true,
|
||
// Required, not optional: a decorative-by-default image is an
|
||
// accessibility regression on a site parents use under time pressure.
|
||
admin: { description: 'Describe the image for screen readers.' },
|
||
},
|
||
],
|
||
};
|
||
```
|
||
|
||
- [ ] **Step 4: Write the Callout block**
|
||
|
||
Create `nextjs-app/blocks/Callout.ts`:
|
||
|
||
```ts
|
||
import type { Block } from 'payload';
|
||
|
||
/**
|
||
* The house block: "what this number doesn't tell you". Blocks are the reason
|
||
* this site runs a CMS rather than flat files — a post can carry live product
|
||
* components, not screenshots. This is the first and simplest one.
|
||
*/
|
||
export const Callout: Block = {
|
||
slug: 'callout',
|
||
labels: { singular: 'Callout', plural: 'Callouts' },
|
||
fields: [
|
||
{
|
||
name: 'tone',
|
||
type: 'select',
|
||
defaultValue: 'caveat',
|
||
options: [
|
||
{ label: 'Caveat — what this does not show', value: 'caveat' },
|
||
{ label: 'Note — useful aside', value: 'note' },
|
||
],
|
||
},
|
||
{ name: 'body', type: 'textarea', required: true },
|
||
],
|
||
};
|
||
```
|
||
|
||
- [ ] **Step 5: Write the Posts collection**
|
||
|
||
Create `nextjs-app/collections/Posts.ts`:
|
||
|
||
```ts
|
||
import type { CollectionConfig } from 'payload';
|
||
import { revalidatePath } from 'next/cache';
|
||
import { lexicalEditor, BlocksFeature } from '@payloadcms/richtext-lexical';
|
||
import { Callout } from '@/blocks/Callout';
|
||
|
||
export const Posts: CollectionConfig = {
|
||
slug: 'posts',
|
||
access: { read: () => true },
|
||
admin: { useAsTitle: 'title', defaultColumns: ['title', 'publishedAt', '_status'] },
|
||
versions: {
|
||
// Posts get written over several sittings and previewed before they go
|
||
// live. Without drafts, saving is publishing.
|
||
drafts: true,
|
||
},
|
||
hooks: {
|
||
/*
|
||
* Blog pages are ISR: CI builds the image with no database reachable, so
|
||
* they cannot be statically generated at build time. These hooks make
|
||
* publication immediate anyway. Payload runs in the same process as Next,
|
||
* so this is a direct revalidatePath call — no webhook, no shared secret.
|
||
*/
|
||
afterChange: [
|
||
({ doc }) => {
|
||
revalidatePath('/blog');
|
||
revalidatePath(`/blog/${doc.slug}`);
|
||
revalidatePath('/content-sitemap.xml');
|
||
},
|
||
],
|
||
afterDelete: [
|
||
({ doc }) => {
|
||
revalidatePath('/blog');
|
||
revalidatePath(`/blog/${doc.slug}`);
|
||
revalidatePath('/content-sitemap.xml');
|
||
},
|
||
],
|
||
},
|
||
fields: [
|
||
{ name: 'title', type: 'text', required: true },
|
||
{
|
||
name: 'slug',
|
||
type: 'text',
|
||
required: true,
|
||
unique: true,
|
||
index: true,
|
||
admin: {
|
||
position: 'sidebar',
|
||
description: 'The URL segment. Never change it after publishing.',
|
||
},
|
||
},
|
||
{
|
||
name: 'publishedAt',
|
||
type: 'date',
|
||
required: true,
|
||
admin: { position: 'sidebar', date: { pickerAppearance: 'dayOnly' } },
|
||
},
|
||
{
|
||
name: 'excerpt',
|
||
type: 'textarea',
|
||
required: true,
|
||
maxLength: 200,
|
||
admin: { description: 'Shown on the index and used as the meta description.' },
|
||
},
|
||
{ name: 'heroImage', type: 'upload', relationTo: 'media' },
|
||
{
|
||
name: 'content',
|
||
type: 'richText',
|
||
required: true,
|
||
editor: lexicalEditor({
|
||
features: ({ defaultFeatures }) => [
|
||
...defaultFeatures,
|
||
BlocksFeature({ blocks: [Callout] }),
|
||
],
|
||
}),
|
||
},
|
||
],
|
||
};
|
||
```
|
||
|
||
- [ ] **Step 6: Register both collections**
|
||
|
||
In `nextjs-app/payload.config.ts`, add the imports and extend the array:
|
||
|
||
```ts
|
||
import { Posts } from '@/collections/Posts';
|
||
import { Media } from '@/collections/Media';
|
||
```
|
||
|
||
```ts
|
||
collections: [Users, Posts, Media],
|
||
```
|
||
|
||
- [ ] **Step 7: Run the tests**
|
||
|
||
```bash
|
||
cd nextjs-app && npm test -- __tests__/payload/collections.test.ts
|
||
```
|
||
|
||
Expected: PASS, 5 tests.
|
||
|
||
- [ ] **Step 8: Generate types and the migration**
|
||
|
||
```bash
|
||
cd nextjs-app && npx payload generate:types && npx payload migrate:create posts_media
|
||
```
|
||
|
||
Commit both `payload-types.ts` and the new migration file.
|
||
|
||
- [ ] **Step 9: Verify and commit**
|
||
|
||
```bash
|
||
cd nextjs-app && npm test && npm run typecheck && npm run build
|
||
git add -A nextjs-app
|
||
git commit -m "feat(blog): add the posts and media collections
|
||
|
||
Drafts are on so a post can be written across sittings without saving
|
||
being publishing. afterChange revalidates the affected paths directly:
|
||
Payload runs in the same process as Next, so publication is immediate
|
||
without a webhook."
|
||
```
|
||
|
||
---
|
||
|
||
### Task 8: The blog
|
||
|
||
**Files:**
|
||
- Create: `nextjs-app/app/(frontend)/blog/page.tsx` + `Blog.module.css`
|
||
- Create: `nextjs-app/app/(frontend)/blog/[slug]/page.tsx` + `Post.module.css`
|
||
- Create: `nextjs-app/components/blog/CalloutBlock.tsx` + `.module.css`
|
||
- Create: `nextjs-app/app/(frontend)/blog/rss.xml/route.ts`
|
||
- Create: `nextjs-app/app/(frontend)/content-sitemap.xml/route.ts`
|
||
- Modify: `nextjs-app/app/(frontend)/robots.ts`
|
||
- Modify: `nextjs-app/lib/jsonld.ts`
|
||
- Create: `nextjs-app/__tests__/app/blogMetadata.test.ts`
|
||
- Modify: `e2e/tests/journeys.spec.ts`
|
||
|
||
**Interfaces:**
|
||
- Consumes: `getCachedPayload()` (Task 3), the `posts` collection (Task 7),
|
||
`personJsonLd`/`organizationJsonLd` (Task 6).
|
||
- Produces: `/blog`, `/blog/[slug]`, `/blog/rss.xml`, `/content-sitemap.xml`.
|
||
|
||
- [ ] **Step 1: Write the failing tests**
|
||
|
||
Create `nextjs-app/__tests__/app/blogMetadata.test.ts`:
|
||
|
||
```ts
|
||
import { metadata } from '@/app/(frontend)/blog/page';
|
||
import { blogPostingJsonLd } from '@/lib/jsonld';
|
||
|
||
describe('/blog metadata', () => {
|
||
it('canonicalises to the bare path', () => {
|
||
expect(metadata.alternates?.canonical)
|
||
.toBe('https://www.schoolcompare.co.uk/blog');
|
||
});
|
||
});
|
||
|
||
describe('BlogPosting structured data', () => {
|
||
const post = {
|
||
title: 'What the data cannot tell you',
|
||
slug: 'what-the-data-cannot-tell-you',
|
||
excerpt: 'Results describe one year group on a handful of days.',
|
||
publishedAt: '2026-09-15T00:00:00.000Z',
|
||
};
|
||
|
||
it('names the same Person entity the about page declares', () => {
|
||
const ld = blogPostingJsonLd(post);
|
||
expect(ld['@type']).toBe('BlogPosting');
|
||
expect(ld.author['@id']).toBe('https://www.schoolcompare.co.uk/about#tudor');
|
||
});
|
||
|
||
it('carries a self-referencing canonical url and the publish date', () => {
|
||
const ld = blogPostingJsonLd(post);
|
||
expect(ld.url).toBe(
|
||
'https://www.schoolcompare.co.uk/blog/what-the-data-cannot-tell-you',
|
||
);
|
||
expect(ld.datePublished).toBe('2026-09-15T00:00:00.000Z');
|
||
});
|
||
});
|
||
```
|
||
|
||
- [ ] **Step 2: Run to verify failure**
|
||
|
||
```bash
|
||
cd nextjs-app && npm test -- __tests__/app/blogMetadata.test.ts
|
||
```
|
||
|
||
Expected: FAIL — neither module exists.
|
||
|
||
- [ ] **Step 3: Extend the JSON-LD builders**
|
||
|
||
Append to `nextjs-app/lib/jsonld.ts`:
|
||
|
||
```ts
|
||
interface PostSummary {
|
||
title: string;
|
||
slug: string;
|
||
excerpt: string;
|
||
publishedAt: string;
|
||
}
|
||
|
||
/**
|
||
* References the Person by @id rather than repeating it, so search engines
|
||
* resolve every post to the one author entity declared on /about.
|
||
*/
|
||
export function blogPostingJsonLd(post: PostSummary) {
|
||
return {
|
||
'@type': 'BlogPosting',
|
||
headline: post.title,
|
||
description: post.excerpt,
|
||
url: absoluteUrl(`/blog/${post.slug}`),
|
||
datePublished: post.publishedAt,
|
||
author: { '@id': `${SITE_URL}/about#tudor` },
|
||
publisher: { '@id': `${SITE_URL}#organization` },
|
||
} as const;
|
||
}
|
||
|
||
export function breadcrumbJsonLd(post: PostSummary) {
|
||
return {
|
||
'@type': 'BreadcrumbList',
|
||
itemListElement: [
|
||
{ '@type': 'ListItem', position: 1, name: 'Blog', item: absoluteUrl('/blog') },
|
||
{
|
||
'@type': 'ListItem',
|
||
position: 2,
|
||
name: post.title,
|
||
item: absoluteUrl(`/blog/${post.slug}`),
|
||
},
|
||
],
|
||
} as const;
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 4: Write the blog index**
|
||
|
||
Create `nextjs-app/app/(frontend)/blog/page.tsx`:
|
||
|
||
```tsx
|
||
import type { Metadata } from 'next';
|
||
import Link from 'next/link';
|
||
import { getCachedPayload } from '@/lib/payload';
|
||
import { absoluteUrl } from '@/lib/site';
|
||
import styles from './Blog.module.css';
|
||
|
||
// ISR, not SSG: CI builds the image with no database, so build-time
|
||
// generateStaticParams would fail or bake in an empty list. Posts publish
|
||
// immediately anyway via the collection's afterChange revalidation hook;
|
||
// this window is only a backstop.
|
||
export const revalidate = 3600;
|
||
|
||
export const metadata: Metadata = {
|
||
title: 'Blog',
|
||
description:
|
||
'Notes on what school performance data shows, and what it does not.',
|
||
alternates: { canonical: absoluteUrl('/blog') },
|
||
};
|
||
|
||
function formatDate(value: string) {
|
||
return new Date(value).toLocaleDateString('en-GB', {
|
||
day: 'numeric',
|
||
month: 'long',
|
||
year: 'numeric',
|
||
});
|
||
}
|
||
|
||
export default async function BlogIndexPage() {
|
||
const payload = await getCachedPayload();
|
||
const { docs } = await payload.find({
|
||
collection: 'posts',
|
||
where: { _status: { equals: 'published' } },
|
||
sort: '-publishedAt',
|
||
limit: 50,
|
||
depth: 0,
|
||
});
|
||
|
||
return (
|
||
<div className={styles.page}>
|
||
<header className={styles.header}>
|
||
<p className={styles.kicker}>Blog</p>
|
||
<h1 className={styles.heading}>Notes on the numbers</h1>
|
||
<p className={styles.standfirst}>
|
||
What school performance data shows, what it doesn't, and how to
|
||
read it without being misled. Written by{' '}
|
||
<Link href="/about" className={styles.link}>Tudor</Link>.
|
||
</p>
|
||
</header>
|
||
|
||
{docs.length === 0 ? (
|
||
<p className={styles.empty}>No posts yet.</p>
|
||
) : (
|
||
<ul className={styles.list}>
|
||
{docs.map((post) => (
|
||
<li key={post.id} className={styles.item}>
|
||
<time className={styles.date} dateTime={post.publishedAt}>
|
||
{formatDate(post.publishedAt)}
|
||
</time>
|
||
<h2 className={styles.itemTitle}>
|
||
<Link href={`/blog/${post.slug}`} className={styles.itemLink}>
|
||
{post.title}
|
||
</Link>
|
||
</h2>
|
||
<p className={styles.excerpt}>{post.excerpt}</p>
|
||
</li>
|
||
))}
|
||
</ul>
|
||
)}
|
||
</div>
|
||
);
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 5: Write the Callout renderer**
|
||
|
||
Create `nextjs-app/components/blog/CalloutBlock.tsx`:
|
||
|
||
```tsx
|
||
import styles from './CalloutBlock.module.css';
|
||
|
||
export function CalloutBlock({ tone, body }: { tone: string; body: string }) {
|
||
return (
|
||
<aside className={tone === 'caveat' ? styles.caveat : styles.note}>
|
||
<p className={styles.body}>{body}</p>
|
||
</aside>
|
||
);
|
||
}
|
||
```
|
||
|
||
Create `nextjs-app/components/blog/CalloutBlock.module.css`:
|
||
|
||
```css
|
||
.caveat, .note {
|
||
border-left: 3px solid var(--brand);
|
||
background: var(--brand-bg);
|
||
padding: 1rem 1.15rem;
|
||
margin: 1.75rem 0;
|
||
border-radius: 0 8px 8px 0;
|
||
}
|
||
|
||
.note { border-left-color: var(--border-strong); background: var(--bg-secondary); }
|
||
|
||
.body {
|
||
font-family: var(--font-ui);
|
||
font-size: 0.95rem;
|
||
line-height: 1.65;
|
||
color: var(--text-primary);
|
||
margin: 0;
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 6: Write the post page**
|
||
|
||
Create `nextjs-app/app/(frontend)/blog/[slug]/page.tsx`:
|
||
|
||
```tsx
|
||
import type { Metadata } from 'next';
|
||
import Link from 'next/link';
|
||
import { notFound } from 'next/navigation';
|
||
import { RichText } from '@payloadcms/richtext-lexical/react';
|
||
import { getCachedPayload } from '@/lib/payload';
|
||
import { absoluteUrl } from '@/lib/site';
|
||
import { blogPostingJsonLd, breadcrumbJsonLd, personJsonLd, organizationJsonLd } from '@/lib/jsonld';
|
||
import { CalloutBlock } from '@/components/blog/CalloutBlock';
|
||
import styles from './Post.module.css';
|
||
|
||
export const revalidate = 3600;
|
||
|
||
async function findPost(slug: string) {
|
||
const payload = await getCachedPayload();
|
||
const { docs } = await payload.find({
|
||
collection: 'posts',
|
||
where: { slug: { equals: slug }, _status: { equals: 'published' } },
|
||
limit: 1,
|
||
depth: 1,
|
||
});
|
||
return docs[0] ?? null;
|
||
}
|
||
|
||
export async function generateMetadata(
|
||
{ params }: { params: Promise<{ slug: string }> },
|
||
): Promise<Metadata> {
|
||
const { slug } = await params;
|
||
const post = await findPost(slug);
|
||
if (!post) return { title: 'Not found' };
|
||
|
||
return {
|
||
title: post.title,
|
||
description: post.excerpt,
|
||
alternates: { canonical: absoluteUrl(`/blog/${post.slug}`) },
|
||
openGraph: {
|
||
type: 'article',
|
||
title: post.title,
|
||
description: post.excerpt,
|
||
url: absoluteUrl(`/blog/${post.slug}`),
|
||
publishedTime: post.publishedAt,
|
||
// A post with a hero image shares that; one without falls through to
|
||
// the root layout's generated card from app/(frontend)/opengraph-image.
|
||
...(post.heroImage?.url ? { images: [{ url: post.heroImage.url }] } : {}),
|
||
},
|
||
};
|
||
}
|
||
|
||
export default async function PostPage(
|
||
{ params }: { params: Promise<{ slug: string }> },
|
||
) {
|
||
const { slug } = await params;
|
||
const post = await findPost(slug);
|
||
if (!post) notFound();
|
||
|
||
const jsonLd = {
|
||
'@context': 'https://schema.org',
|
||
'@graph': [
|
||
blogPostingJsonLd(post),
|
||
breadcrumbJsonLd(post),
|
||
personJsonLd(),
|
||
organizationJsonLd(),
|
||
],
|
||
};
|
||
|
||
return (
|
||
<article className={styles.page}>
|
||
<script
|
||
type="application/ld+json"
|
||
dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd) }}
|
||
/>
|
||
|
||
<nav className={styles.crumb}>
|
||
<Link href="/blog" className={styles.link}>Blog</Link>
|
||
</nav>
|
||
|
||
<h1 className={styles.heading}>{post.title}</h1>
|
||
|
||
<p className={styles.byline}>
|
||
By <Link href="/about" className={styles.link}>Tudor</Link>
|
||
{' · '}
|
||
<time dateTime={post.publishedAt}>
|
||
{new Date(post.publishedAt).toLocaleDateString('en-GB', {
|
||
day: 'numeric', month: 'long', year: 'numeric',
|
||
})}
|
||
</time>
|
||
</p>
|
||
|
||
{/*
|
||
A plain <img>, not next/image: Payload already generated the sized
|
||
derivatives on upload (Media's imageSizes), so routing it through the
|
||
optimizer would resize an image that is already the right size.
|
||
*/}
|
||
{post.heroImage?.url && (
|
||
<img
|
||
className={styles.hero}
|
||
src={post.heroImage.url}
|
||
alt={post.heroImage.alt ?? ''}
|
||
width={post.heroImage.width ?? undefined}
|
||
height={post.heroImage.height ?? undefined}
|
||
/>
|
||
)}
|
||
|
||
<div className={styles.prose}>
|
||
<RichText
|
||
data={post.content}
|
||
blocks={{ callout: ({ node }) => <CalloutBlock {...node.fields} /> }}
|
||
/>
|
||
</div>
|
||
</article>
|
||
);
|
||
}
|
||
```
|
||
|
||
Create `nextjs-app/app/(frontend)/blog/Blog.module.css`:
|
||
|
||
```css
|
||
.page { max-width: 42rem; margin: 0 auto; padding: 2.5rem 1.25rem 4rem; }
|
||
|
||
.header { margin-bottom: 2.5rem; }
|
||
|
||
.kicker {
|
||
font-family: var(--font-ui);
|
||
font-size: 0.75rem;
|
||
font-weight: 600;
|
||
text-transform: uppercase;
|
||
letter-spacing: 0.06em;
|
||
color: var(--brand);
|
||
margin: 0 0 0.35rem;
|
||
}
|
||
|
||
.heading {
|
||
font-family: var(--font-display);
|
||
font-size: clamp(1.5rem, 4vw, 2rem);
|
||
font-weight: 700;
|
||
line-height: 1.2;
|
||
color: var(--text-primary);
|
||
margin: 0 0 0.75rem;
|
||
}
|
||
|
||
.standfirst {
|
||
font-family: var(--font-ui);
|
||
font-size: 1.05rem;
|
||
line-height: 1.65;
|
||
color: var(--text-secondary);
|
||
margin: 0;
|
||
}
|
||
|
||
.list { list-style: none; padding: 0; margin: 0; }
|
||
|
||
.item {
|
||
padding: 1.5rem 0;
|
||
border-top: 1px solid var(--border);
|
||
}
|
||
|
||
.date {
|
||
font-family: var(--font-ui);
|
||
font-size: 0.8rem;
|
||
color: var(--text-muted);
|
||
/* Inter's tabular numerals keep a column of dates aligned. */
|
||
font-variant-numeric: tabular-nums;
|
||
}
|
||
|
||
.itemTitle {
|
||
font-family: var(--font-display);
|
||
font-size: 1.25rem;
|
||
font-weight: 600;
|
||
line-height: 1.3;
|
||
margin: 0.35rem 0 0.5rem;
|
||
}
|
||
|
||
.itemLink { color: var(--text-primary); text-decoration: none; }
|
||
.itemLink:hover { color: var(--brand); }
|
||
|
||
.excerpt {
|
||
font-family: var(--font-ui);
|
||
font-size: 0.95rem;
|
||
line-height: 1.65;
|
||
color: var(--text-secondary);
|
||
margin: 0;
|
||
}
|
||
|
||
.empty {
|
||
font-family: var(--font-ui);
|
||
color: var(--text-muted);
|
||
}
|
||
|
||
.link { color: var(--brand); font-weight: 600; }
|
||
.link:hover { color: var(--brand-strong); }
|
||
```
|
||
|
||
Create `nextjs-app/app/(frontend)/blog/[slug]/Post.module.css`:
|
||
|
||
```css
|
||
.page { max-width: 42rem; margin: 0 auto; padding: 2.5rem 1.25rem 4rem; }
|
||
|
||
.crumb {
|
||
font-family: var(--font-ui);
|
||
font-size: 0.85rem;
|
||
margin-bottom: 1.25rem;
|
||
}
|
||
|
||
.heading {
|
||
font-family: var(--font-display);
|
||
font-size: clamp(1.6rem, 5vw, 2.25rem);
|
||
font-weight: 700;
|
||
line-height: 1.2;
|
||
color: var(--text-primary);
|
||
margin: 0 0 0.75rem;
|
||
}
|
||
|
||
.byline {
|
||
font-family: var(--font-ui);
|
||
font-size: 0.9rem;
|
||
color: var(--text-muted);
|
||
margin: 0 0 2rem;
|
||
}
|
||
|
||
.hero {
|
||
width: 100%;
|
||
height: auto;
|
||
border-radius: 10px;
|
||
border: 1px solid var(--border);
|
||
margin-bottom: 2rem;
|
||
}
|
||
|
||
/* Rich-text output: the editor emits plain elements, so they are styled by
|
||
descendant selector rather than by class. */
|
||
.prose p {
|
||
font-family: var(--font-ui);
|
||
font-size: 1rem;
|
||
line-height: 1.7;
|
||
color: var(--text-secondary);
|
||
margin: 0 0 1.1rem;
|
||
}
|
||
|
||
.prose h2 {
|
||
font-family: var(--font-display);
|
||
font-size: 1.25rem;
|
||
font-weight: 600;
|
||
color: var(--text-primary);
|
||
margin: 2.25rem 0 0.75rem;
|
||
}
|
||
|
||
.prose h3 {
|
||
font-family: var(--font-display);
|
||
font-size: 1.05rem;
|
||
font-weight: 600;
|
||
color: var(--text-primary);
|
||
margin: 1.75rem 0 0.6rem;
|
||
}
|
||
|
||
.prose ul, .prose ol {
|
||
font-family: var(--font-ui);
|
||
font-size: 1rem;
|
||
line-height: 1.7;
|
||
color: var(--text-secondary);
|
||
padding-left: 1.35rem;
|
||
margin: 0 0 1.1rem;
|
||
}
|
||
|
||
.prose li { margin-bottom: 0.4rem; }
|
||
|
||
.prose a { color: var(--brand); font-weight: 500; }
|
||
.prose a:hover { color: var(--brand-strong); }
|
||
|
||
.prose blockquote {
|
||
border-left: 3px solid var(--border-strong);
|
||
padding-left: 1rem;
|
||
margin: 1.5rem 0;
|
||
color: var(--text-muted);
|
||
font-style: italic;
|
||
}
|
||
|
||
.link { color: var(--brand); font-weight: 600; }
|
||
.link:hover { color: var(--brand-strong); }
|
||
```
|
||
|
||
- [ ] **Step 7: Write the RSS feed**
|
||
|
||
Create `nextjs-app/app/(frontend)/blog/rss.xml/route.ts`:
|
||
|
||
```ts
|
||
import { getCachedPayload } from '@/lib/payload';
|
||
import { absoluteUrl, SITE_URL } from '@/lib/site';
|
||
|
||
export const revalidate = 3600;
|
||
|
||
function escapeXml(value: string): string {
|
||
return value.replace(/[<>&'"]/g, (char) =>
|
||
({ '<': '<', '>': '>', '&': '&', "'": ''', '"': '"' }[char]!));
|
||
}
|
||
|
||
export async function GET() {
|
||
const payload = await getCachedPayload();
|
||
const { docs } = await payload.find({
|
||
collection: 'posts',
|
||
where: { _status: { equals: 'published' } },
|
||
sort: '-publishedAt',
|
||
limit: 50,
|
||
depth: 0,
|
||
});
|
||
|
||
const items = docs.map((post) => `
|
||
<item>
|
||
<title>${escapeXml(post.title)}</title>
|
||
<link>${absoluteUrl(`/blog/${post.slug}`)}</link>
|
||
<guid isPermaLink="true">${absoluteUrl(`/blog/${post.slug}`)}</guid>
|
||
<description>${escapeXml(post.excerpt)}</description>
|
||
<pubDate>${new Date(post.publishedAt).toUTCString()}</pubDate>
|
||
</item>`).join('');
|
||
|
||
const xml = `<?xml version="1.0" encoding="UTF-8"?>
|
||
<rss version="2.0">
|
||
<channel>
|
||
<title>schoolcompare blog</title>
|
||
<link>${absoluteUrl('/blog')}</link>
|
||
<description>What school performance data shows, and what it does not.</description>
|
||
<language>en-GB</language>${items}
|
||
</channel>
|
||
</rss>`;
|
||
|
||
return new Response(xml, {
|
||
headers: { 'Content-Type': 'application/rss+xml; charset=utf-8' },
|
||
});
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 8: Write the content sitemap**
|
||
|
||
`/sitemap.xml` is proxied from FastAPI, which knows nothing about Payload. A
|
||
second sitemap covers the Next-owned URLs, and `robots.txt` lists both.
|
||
|
||
Create `nextjs-app/app/(frontend)/content-sitemap.xml/route.ts`:
|
||
|
||
```ts
|
||
import { getCachedPayload } from '@/lib/payload';
|
||
import { absoluteUrl } from '@/lib/site';
|
||
|
||
export const revalidate = 3600;
|
||
|
||
export async function GET() {
|
||
const payload = await getCachedPayload();
|
||
const { docs } = await payload.find({
|
||
collection: 'posts',
|
||
where: { _status: { equals: 'published' } },
|
||
sort: '-publishedAt',
|
||
limit: 500,
|
||
depth: 0,
|
||
});
|
||
|
||
const urls = [
|
||
{ loc: absoluteUrl('/about'), lastmod: null },
|
||
{ loc: absoluteUrl('/blog'), lastmod: null },
|
||
...docs.map((post) => ({
|
||
loc: absoluteUrl(`/blog/${post.slug}`),
|
||
lastmod: new Date(post.updatedAt ?? post.publishedAt).toISOString(),
|
||
})),
|
||
];
|
||
|
||
const xml = `<?xml version="1.0" encoding="UTF-8"?>
|
||
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
|
||
${urls.map(({ loc, lastmod }) =>
|
||
` <url><loc>${loc}</loc>${lastmod ? `<lastmod>${lastmod}</lastmod>` : ''}</url>`,
|
||
).join('\n')}
|
||
</urlset>`;
|
||
|
||
return new Response(xml, {
|
||
headers: { 'Content-Type': 'application/xml; charset=utf-8' },
|
||
});
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 9: List both sitemaps in robots.txt**
|
||
|
||
In `nextjs-app/app/(frontend)/robots.ts`, change the `sitemap` key to:
|
||
|
||
```ts
|
||
sitemap: [absoluteUrl('/sitemap.xml'), absoluteUrl('/content-sitemap.xml')],
|
||
```
|
||
|
||
Extend the robots test from Task 4 to assert both are listed:
|
||
|
||
```ts
|
||
it('lists both the proxied school sitemap and the Next-owned content sitemap', () => {
|
||
expect(robots().sitemap).toEqual([
|
||
'https://www.schoolcompare.co.uk/sitemap.xml',
|
||
'https://www.schoolcompare.co.uk/content-sitemap.xml',
|
||
]);
|
||
});
|
||
```
|
||
|
||
- [ ] **Step 10: Run the tests**
|
||
|
||
```bash
|
||
cd nextjs-app && npm test -- __tests__/app/blogMetadata.test.ts __tests__/app/robots.test.ts
|
||
```
|
||
|
||
Expected: PASS.
|
||
|
||
- [ ] **Step 11: Add the e2e journeys**
|
||
|
||
Append to `e2e/tests/journeys.spec.ts`:
|
||
|
||
```ts
|
||
test('the blog lists posts and each one renders with a byline', async ({ page }) => {
|
||
await page.goto('/blog');
|
||
await expect(page.getByRole('heading', { level: 1 })).toBeVisible();
|
||
|
||
const postLinks = page.locator('a[href^="/blog/"]');
|
||
const count = await postLinks.count();
|
||
// Data invariant: staging must carry at least one published post.
|
||
expect(count).toBeGreaterThan(0);
|
||
|
||
await postLinks.first().click();
|
||
await page.waitForURL(/\/blog\/.+/);
|
||
await expect(page.getByRole('heading', { level: 1 })).toBeVisible();
|
||
await expect(page.getByText(/^By Tudor/)).toBeVisible();
|
||
|
||
const jsonLd = await page.locator('script[type="application/ld+json"]').first().textContent();
|
||
expect(jsonLd).toContain('"BlogPosting"');
|
||
});
|
||
|
||
test('the admin panel is not indexable', async ({ page }) => {
|
||
const response = await page.request.get('/admin');
|
||
expect(response.headers()['x-robots-tag']).toContain('noindex');
|
||
});
|
||
|
||
test('the content sitemap lists the about page', async ({ page }) => {
|
||
const response = await page.request.get('/content-sitemap.xml');
|
||
expect(response.ok()).toBeTruthy();
|
||
expect(await response.text()).toContain('/about');
|
||
});
|
||
```
|
||
|
||
- [ ] **Step 12: Verify and commit**
|
||
|
||
```bash
|
||
cd nextjs-app && npm test && npm run typecheck && npm run build
|
||
git add -A nextjs-app e2e
|
||
git commit -m "feat(blog): add the blog index, post pages, RSS and sitemap
|
||
|
||
ISR rather than static generation: CI builds with no database, so
|
||
build-time generateStaticParams would bake in an empty list. The
|
||
collection's afterChange hook revalidates on publish, so the hour-long
|
||
window is only a backstop.
|
||
|
||
/sitemap.xml is proxied from FastAPI, which knows nothing about Payload,
|
||
so Next-owned URLs get their own sitemap and robots.txt lists both."
|
||
```
|
||
|
||
---
|
||
|
||
### Task 9: First post and publishing documentation
|
||
|
||
**Files:**
|
||
- Create: `nextjs-app/docs/PUBLISHING.md`
|
||
- Modify: `CLAUDE.md`
|
||
|
||
**Interfaces:**
|
||
- Consumes: everything above.
|
||
- Produces: nothing.
|
||
|
||
- [ ] **Step 1: Write the publishing guide**
|
||
|
||
Create `nextjs-app/docs/PUBLISHING.md` covering: signing in at
|
||
`/admin`; creating a post (title, slug, publishedAt, excerpt, content); saving
|
||
as draft versus publishing; that publishing revalidates the live page within
|
||
seconds via the `afterChange` hook; that the slug must never change after
|
||
publication because it is the canonical URL; and that images need alt text.
|
||
|
||
Include the voice rules from this plan's Global Constraints verbatim, so the
|
||
standard survives without this plan being to hand.
|
||
|
||
- [ ] **Step 2: Update CLAUDE.md**
|
||
|
||
Add to the architecture section: the `(frontend)` / `(payload)` route-group
|
||
split and why it exists; Payload at `/admin` with its API at `/cms-api`; the
|
||
`payload` Postgres schema; the `payload_media` volume; and the two new
|
||
environment variables.
|
||
|
||
- [ ] **Step 3: Commit**
|
||
|
||
```bash
|
||
git add -A nextjs-app CLAUDE.md
|
||
git commit -m "docs(blog): how to publish, and why the app has two route groups"
|
||
```
|
||
|
||
- [ ] **Step 4: Write the first post (human task, after deploy)**
|
||
|
||
This step is not code and cannot be done before the stack is deployed and the
|
||
first admin user exists. In the admin panel, write and publish the first post.
|
||
|
||
Suggested subject: **what school performance data cannot tell you** — the
|
||
argument already drafted on the About page, expanded. It demonstrates judgement,
|
||
is genuinely useful to a parent, and is the kind of thing neither an anonymous
|
||
site nor a generated one publishes. Use one `callout` block for the small-cohort
|
||
caveat.
|
||
|
||
The blog e2e journey asserts at least one published post exists, so this must
|
||
be done before the staging gate can pass.
|
||
|
||
---
|
||
|
||
## Verification Checklist
|
||
|
||
Before opening the PR:
|
||
|
||
- [ ] `cd nextjs-app && npm test` — all green
|
||
- [ ] `cd nextjs-app && npm run typecheck` — clean
|
||
- [ ] `cd nextjs-app && npm run build` — succeeds **without a database**
|
||
- [ ] `docker build -t sc-frontend-test nextjs-app` — succeeds
|
||
- [ ] `git status --short` — no unintended untracked files
|
||
- [ ] Build output shows `/`, `/rankings`, `/compare`, `/admissions`,
|
||
`/about`, `/blog` — and **no** path containing `(frontend)`
|
||
- [ ] No hardcoded hex colours in new CSS; both themes checked
|
||
- [ ] No surname, employer or child's name anywhere in the diff
|
||
- [ ] PR description lists the operational follow-ups from Task 5 Step 8
|
||
|
||
## Post-merge (human, on staging)
|
||
|
||
- [ ] `PAYLOAD_SECRET` set in both Portainer stacks, different values
|
||
- [ ] First admin user seeded
|
||
- [ ] An image uploads successfully — proves the volume permissions
|
||
- [ ] Publishing a post revalidates `/blog` within seconds
|
||
- [ ] `curl -I https://stx.schoolcompare.co.uk/` still returns
|
||
`X-Robots-Tag: noindex, nofollow` — the Task 1 conversion's real test
|
||
- [ ] `payload_media` added to the backup routine
|