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, verified against the build's route table. The metadata file conventions deliberately stay at the app/ root. Moving them into the group renamed /icon.png to /icon-4usi79.png (likewise apple-icon and opengraph-image) and dropped /robots.txt altogether, which would have broken the /icon.png cache-control rule, the outputFileTracingIncludes entry for the share card, and robots.txt. darkThemeSafety reads app/globals.css off disk rather than importing it, so it needed its own path fix — a grep for import specifiers misses it, and it fails as an unrunnable suite rather than a failed assertion. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017YmbBhr8s7GusjDE12hrZM
2310 lines
70 KiB
Markdown
2310 lines
70 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`, `rankings/`, `admissions/`,
|
||
`compare/`, `schools/`, `school/`, `api/`, `sitemaps/`, `sitemap.xml/`.
|
||
|
||
**Deliberately NOT moved — they stay at the `app/` root:** `robots.ts`,
|
||
`opengraph-image.tsx`, `icon.png`, `apple-icon.png`.
|
||
|
||
Next.js metadata file conventions only produce stable root URLs at the `app/`
|
||
root. Inside a route group they are treated as segment-scoped: verified during
|
||
execution, moving them into `(frontend)` renamed `/icon.png` to
|
||
`/icon-4usi79.png`, `/apple-icon.png` to `/apple-icon-4usi79.png`,
|
||
`/opengraph-image` to `/opengraph-image-4usi79`, and dropped `/robots.txt`
|
||
entirely. That would have broken the `/icon.png` cache-control rule and the
|
||
`outputFileTracingIncludes['/opengraph-image']` entry in `next.config.mjs`,
|
||
and silently removed the site's robots.txt. Route handlers (`sitemap.xml/`,
|
||
`sitemaps/`, `api/`) are unaffected and move normally.
|
||
|
||
**Created:**
|
||
|
||
| Path | Responsibility |
|
||
|---|---|
|
||
| `nextjs-app/next.config.mjs` | Replaces `next.config.js`; ESM so it can wrap `withPayload()` |
|
||
| `nextjs-app/payload.config.ts` | Payload's single source of truth: DB, routes, collections |
|
||
| `nextjs-app/collections/Users.ts` | Admin auth collection, lockout policy, closed registration |
|
||
| `nextjs-app/collections/Posts.ts` | Blog post schema, drafts, revalidation hooks |
|
||
| `nextjs-app/collections/Media.ts` | Upload collection writing to `/app/media` |
|
||
| `nextjs-app/blocks/Callout.ts` | The "what this number doesn't tell you" block |
|
||
| `nextjs-app/app/(payload)/**` | Generated Payload admin + `/cms-api` routes |
|
||
| `nextjs-app/migrations/**` | Committed Payload schema migrations |
|
||
| `nextjs-app/app/(frontend)/about/page.tsx` + `.module.css` | The About page |
|
||
| `nextjs-app/app/(frontend)/blog/page.tsx` + `.module.css` | Blog index |
|
||
| `nextjs-app/app/(frontend)/blog/[slug]/page.tsx` + `.module.css` | Post page |
|
||
| `nextjs-app/app/(frontend)/blog/rss.xml/route.ts` | RSS feed |
|
||
| `nextjs-app/app/(frontend)/content-sitemap.xml/route.ts` | Sitemap for Next-owned URLs |
|
||
| `nextjs-app/lib/payload.ts` | Cached `getPayload()` accessor |
|
||
| `nextjs-app/lib/jsonld.ts` | `Person` / `Organization` / `BlogPosting` builders |
|
||
|
||
**Modified:** `package.json`, `tsconfig.json`, `Dockerfile`,
|
||
`docker-compose.portainer.yml`, `docker-compose.portainer.staging.yml`,
|
||
`components/Footer.tsx`, `__tests__/app/metadata.test.ts`,
|
||
`__tests__/api/proxyDenylist.test.ts`, `__tests__/app/placeMetadata.test.ts`,
|
||
`e2e/tests/journeys.spec.ts`.
|
||
|
||
---
|
||
|
||
### Task 1: Convert `next.config.js` to ESM
|
||
|
||
`withPayload()` is ESM-only, so the config must become `.mjs`. This file also
|
||
carries the rule that keeps staging out of Google's index — the highest-value
|
||
thing in the repo to break silently — so it is converted first, on its own,
|
||
behind a test.
|
||
|
||
**Files:**
|
||
- Create: `nextjs-app/__tests__/app/nextConfig.test.ts`
|
||
- Create: `nextjs-app/next.config.mjs`
|
||
- Delete: `nextjs-app/next.config.js`
|
||
|
||
**Interfaces:**
|
||
- Consumes: nothing.
|
||
- Produces: `next.config.mjs` default-exporting the Next config object. Task 3
|
||
wraps this export in `withPayload()`.
|
||
|
||
- [ ] **Step 1: Write the failing test**
|
||
|
||
Create `nextjs-app/__tests__/app/nextConfig.test.ts`:
|
||
|
||
```ts
|
||
/**
|
||
* next.config.mjs carries the staging noindex rule. Breaking it turns
|
||
* stx.schoolcompare.co.uk into a fully crawlable duplicate of production,
|
||
* and nothing else in the suite would notice.
|
||
*/
|
||
import nextConfig from '@/next.config.mjs';
|
||
|
||
describe('next.config.mjs', () => {
|
||
it('keeps the staging host out of the index', async () => {
|
||
const headers = await nextConfig.headers();
|
||
const stagingRule = headers.find((rule) =>
|
||
rule.has?.some(
|
||
(cond) => cond.type === 'host' && cond.value === 'stx.schoolcompare.co.uk',
|
||
),
|
||
);
|
||
expect(stagingRule).toBeDefined();
|
||
expect(stagingRule.headers).toContainEqual({
|
||
key: 'X-Robots-Tag',
|
||
value: 'noindex, nofollow',
|
||
});
|
||
});
|
||
|
||
it('still emits standalone output for the Docker runner', () => {
|
||
expect(nextConfig.output).toBe('standalone');
|
||
});
|
||
|
||
it('still traces the share-card fonts into the standalone bundle', () => {
|
||
expect(nextConfig.outputFileTracingIncludes['/opengraph-image']).toEqual([
|
||
'./assets/**',
|
||
]);
|
||
});
|
||
|
||
it('still allows the analytics subdomain to frame the site', async () => {
|
||
const headers = await nextConfig.headers();
|
||
const csp = headers
|
||
.flatMap((rule) => rule.headers)
|
||
.find((header) => header.key === 'Content-Security-Policy');
|
||
expect(csp.value).toContain('https://analytics.schoolcompare.co.uk');
|
||
});
|
||
});
|
||
```
|
||
|
||
- [ ] **Step 2: Run the test to verify it fails**
|
||
|
||
```bash
|
||
cd nextjs-app && npm test -- __tests__/app/nextConfig.test.ts
|
||
```
|
||
|
||
Expected: FAIL — cannot resolve `@/next.config.mjs` (the file does not exist yet).
|
||
|
||
If it instead fails with an ESM parse error after Step 3, add `'mjs'` to
|
||
`moduleFileExtensions` in `jest.config.js`:
|
||
```js
|
||
moduleFileExtensions: ['ts', 'tsx', 'js', 'jsx', 'mjs', 'json', 'node'],
|
||
```
|
||
|
||
- [ ] **Step 3: Convert the config**
|
||
|
||
```bash
|
||
cd nextjs-app && git mv next.config.js next.config.mjs
|
||
```
|
||
|
||
Then edit `next.config.mjs`: change the final line from
|
||
`module.exports = nextConfig;` to `export default nextConfig;`.
|
||
|
||
Change nothing else. Every comment block in that file documents a past
|
||
production incident — the baked-in `FASTAPI_URL`, the staging `X-Robots-Tag`
|
||
reasoning, the `/icon.png` cache 404 — and all of it must survive verbatim.
|
||
|
||
- [ ] **Step 4: Run the test to verify it passes**
|
||
|
||
```bash
|
||
cd nextjs-app && npm test -- __tests__/app/nextConfig.test.ts
|
||
```
|
||
|
||
Expected: PASS, 4 tests.
|
||
|
||
- [ ] **Step 5: Verify the whole suite and the build still pass**
|
||
|
||
```bash
|
||
cd nextjs-app && npm test && npm run typecheck && npm run build
|
||
```
|
||
|
||
Expected: all green. `next/jest` resolves `.mjs` configs, so the existing
|
||
suite is unaffected.
|
||
|
||
- [ ] **Step 6: Commit**
|
||
|
||
```bash
|
||
git add nextjs-app/next.config.mjs nextjs-app/__tests__/app/nextConfig.test.ts nextjs-app/jest.config.js
|
||
git commit -m "build(next): convert the config to ESM so Payload can wrap it
|
||
|
||
withPayload() is ESM-only. This file also carries the rule that keeps
|
||
staging out of the index, so the conversion goes in on its own behind a
|
||
test that asserts the rule survived."
|
||
```
|
||
|
||
---
|
||
|
||
### Task 2: Move site routes into an `app/(frontend)` route group
|
||
|
||
This creates room for Payload's admin panel to be its own root layout. No URL
|
||
changes: route groups are invisible to routing.
|
||
|
||
**Files:**
|
||
- Move: everything currently under `nextjs-app/app/` → `nextjs-app/app/(frontend)/`
|
||
- Modify: `nextjs-app/__tests__/app/metadata.test.ts:1-4`
|
||
- Modify: `nextjs-app/__tests__/app/placeMetadata.test.ts:1`
|
||
- Modify: `nextjs-app/__tests__/api/proxyDenylist.test.ts:11`
|
||
|
||
**Interfaces:**
|
||
- Consumes: Task 1's `next.config.mjs`.
|
||
- Produces: no `app/layout.tsx` at the app root — the precondition Task 3
|
||
requires. Site routes importable as `@/app/(frontend)/<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 \
|
||
rankings admissions compare schools school api sitemaps sitemap.xml \
|
||
"(frontend)/"
|
||
```
|
||
|
||
`robots.ts`, `opengraph-image.tsx`, `icon.png` and `apple-icon.png` stay at the
|
||
`app/` root — see "Deliberately NOT moved" in the File Structure section above.
|
||
Moving them silently changes their URLs and drops robots.txt.
|
||
|
||
Verify the split — `app/` should now contain `(frontend)` plus exactly the four
|
||
metadata conventions:
|
||
|
||
```bash
|
||
cd /Users/tudor/projects/school_compare/nextjs-app && ls -A app
|
||
```
|
||
|
||
Expected: `(frontend)`, `apple-icon.png`, `icon.png`, `opengraph-image.tsx`,
|
||
`robots.ts`. If a route directory is still there, move it. `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**
|
||
|
||
Two searches, because import specifiers are not the only way a file names a
|
||
path. Quote the `--include` globs or zsh expands them.
|
||
|
||
```bash
|
||
cd /Users/tudor/projects/school_compare
|
||
# 1. Import specifiers
|
||
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)"
|
||
|
||
# 2. Filesystem paths — readFileSync/path.join targets, which search 1 misses.
|
||
# __tests__/components/darkThemeSafety.test.ts reads app/globals.css this way
|
||
# and fails to *run* when the path is stale, so it shows as a failed suite
|
||
# rather than a failed assertion.
|
||
grep -rn "'app'" nextjs-app/__tests__ --include="*.ts" --include="*.tsx" \
|
||
| grep -v "(frontend)"
|
||
```
|
||
|
||
Expected: no output from either. Fix anything that appears — note that
|
||
`renderSchoolDetail.tsx` names `app/layout.tsx` in a comment, which should be
|
||
updated for accuracy even though nothing breaks.
|
||
|
||
- [ ] **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/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/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/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/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/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
|