diff --git a/claude.md b/claude.md
index 3d05e66..057a630 100644
--- a/claude.md
+++ b/claude.md
@@ -23,6 +23,46 @@ Key files:
- `backend/data_loader.py` - Data queries, geocoding, legacy DataFrame compatibility
- `backend/schemas.py` - Column mappings, metric definitions, LA code mappings
+### Content / CMS (Payload)
+
+Payload CMS runs **inside** the Next.js app — one image, one container, no
+separate service. It powers `/blog`; `/about` is a plain coded page.
+
+- **Admin panel:** `/admin`. The only authenticated surface on the site.
+ `noindex` via both `robots.txt` and `X-Robots-Tag`.
+- **CMS API:** `/cms-api`, **not** `/api`. `/api/*` is a catch-all proxy to
+ FastAPI (`app/(frontend)/api/[...path]`) which would silently swallow every
+ admin call and forward it to the backend. Mount points are defined once in
+ `lib/payloadRoutes.ts`.
+- **Database:** the existing Postgres, in its own `payload` schema, so no
+ pipeline operation on `public` — including
+ `scripts/migrate_csv_to_db.py --drop` — can reach blog content.
+- **Uploads:** the `payload_media` Docker volume at `/app/media`. Not
+ reproducible from the pipeline; must be backed up.
+- **New env vars:** `DATABASE_URL` and `PAYLOAD_SECRET` on the frontend service.
+ Staging must use a different `PAYLOAD_SECRET` from production.
+- Publishing workflow and house style: `nextjs-app/docs/PUBLISHING.md`.
+
+### Two route groups
+
+`nextjs-app/app/` has no root `layout.tsx`. It cannot: Payload's admin panel
+ships its own root layout rendering ``/`
`, and Next permits
+multiple root layouts only when no `app/layout.tsx` exists.
+
+- `app/(frontend)/` — the site. Its `layout.tsx` is the site's root layout.
+- `app/(payload)/` — the admin panel and `/cms-api`.
+
+Route groups are invisible to routing, so every public URL is unchanged.
+
+**The metadata file conventions stay at the `app/` root** — `robots.ts`,
+`opengraph-image.tsx`, `icon.png`, `apple-icon.png`. Inside a route group Next
+treats them as segment-scoped: it renames `/icon.png` to `/icon-.png` and
+drops `/robots.txt` entirely. Route handlers are unaffected.
+
+The build must succeed with `DATABASE_URL` unset, because CI builds it that
+way. Never call `getCachedPayload()` at module scope, and never add
+`generateStaticParams` to a DB-backed route.
+
### Frontend (Vanilla JS)
- Single-page application with hash-based routing
- Chart.js for data visualization
diff --git a/nextjs-app/docs/PUBLISHING.md b/nextjs-app/docs/PUBLISHING.md
new file mode 100644
index 0000000..75a6eb6
--- /dev/null
+++ b/nextjs-app/docs/PUBLISHING.md
@@ -0,0 +1,82 @@
+# Publishing to the blog
+
+The blog is Payload CMS, running inside the Next.js app. There is no separate
+service and no second deploy — writing a post is done in the browser and takes
+effect on the live site within seconds.
+
+## Signing in
+
+`https://www.schoolcompare.co.uk/admin` — one account, no registration. If you
+need the account seeded on a fresh environment, run against the container:
+
+```bash
+npx payload create-first-user
+```
+
+Staging has its own admin panel, its own database and its own credentials at
+`https://stx.schoolcompare.co.uk/admin`. Never reuse production's secret or
+password there.
+
+## Writing a post
+
+**Posts → Create New.** The fields:
+
+| Field | Notes |
+|---|---|
+| **Title** | The `` and the browser tab. |
+| **Slug** | The URL segment, in the sidebar. **Never change it after publishing** — it is the canonical URL, and changing it breaks every existing link and discards the page's accumulated search signal. |
+| **Published at** | The date shown on the post and in the feed. |
+| **Excerpt** | Max 200 characters. Shown on the index *and* used as the meta description, so write it as a standalone sentence rather than a teaser. |
+| **Hero image** | Optional. Becomes the social share image; without one, the site's generated card is used. |
+| **Content** | Rich text. `/` inserts a block. |
+
+**Save as draft** while you're working — drafts are not public. **Publish** when
+it's ready.
+
+### The callout block
+
+One custom block, `Callout`, with two tones:
+
+- **Caveat** — what a number does *not* show. This is the one that matters: it
+ is how a post states a limitation in context rather than burying it in a
+ closing paragraph.
+- **Note** — a useful aside.
+
+### Images
+
+Every image requires alt text; the editor will not let you save without it.
+Uploads go to a Docker volume on the host, which is backed up separately from
+Postgres — an image is not reproducible from the pipeline the way school data
+is.
+
+## How publishing reaches the live site
+
+- `/blog`, `/blog/rss.xml` and `/content-sitemap.xml` are rendered per request,
+ so a new post appears immediately.
+- `/blog/[slug]` is cached after its first request. Publishing or editing fires
+ a `revalidatePath` from the collection's `afterChange` hook, which drops that
+ cached copy — so edits appear immediately too.
+
+If a change doesn't show, it is far more likely the post is still a draft than
+that the cache is stale.
+
+## House style
+
+These rules are why the blog exists. A post that ignores them makes the site
+read more machine-generated, not less.
+
+- **First person singular.** "I built", "I found" — never "we provide".
+- **Concrete over general.** "When we were looking at schools in Wandsworth"
+ beats any amount of stated warmth.
+- **State limits before someone else finds them.** Every post that presents a
+ metric says what it does not show. This is the single strongest signal that a
+ human wrote it: generated content does not volunteer its own weaknesses.
+- **No mission statements, no "passionate about", no invented team.** There is
+ one person here.
+- **Short sentences.**
+- **Never publish a surname, an employer, or a child's name.** The site's author
+ is "Tudor". See `/about`.
+- **Never invent a figure**, even illustratively. On a site whose whole
+ proposition is official data, a made-up number attached to a real school is
+ the one thing it cannot do — and no illustrative intent survives being
+ screenshotted.