Files
school_compare/nextjs-app/docs/PUBLISHING.md
TudorandClaude Opus 5 6fc7fce948
PR Checks / Frontend Typecheck + Tests (pull_request) Successful in 1m15s
PR Checks / Backend Smoke (pull_request) Successful in 9s
PR Checks / Build Backend (no push) (pull_request) Successful in 20s
PR Checks / Build Frontend (no push) (pull_request) Successful in 1m21s
PR Checks / Build Pipeline (no push) (pull_request) Successful in 12s
PR Checks / AI Code Review (Claude) (pull_request) Successful in 1m39s
feat(flags): put /about and /blog behind flags, dark by default
Both features ship dark. Neither is reachable in an environment where
its flag is off, and every flag in this system starts off, so a deploy
of this commit makes both disappear until someone turns them on
deliberately.

Two independent flags rather than one, which makes blog-on-about-off a
reachable state. That state is the whole reason the change is larger
than four notFound() calls: the blog leans on the About page for its
author identity. The Person entity is anchored at /about#tudor, and
that URL 404s while about_page is dark, so a post published in that
state would claim an author resolving to nothing. Worse than having no
named author. Both bylines fall back to unlinked text and the
BlogPosting attributes to the publisher instead, so every combination
of the two flags renders something correct.

Gated: /about, /blog, /blog/[slug], the RSS feed, both footer links,
and the matching content-sitemap entries. A sitemap must never
advertise a URL that 404s. With both dark it emits a valid empty
urlset rather than a 404, because robots.txt names it unconditionally.

Not gated: /admin. Posts have to be writable before the blog is worth
switching on, so flagging the panel would make the flag unflippable.

getFlags takes a revalidate rather than always using the 300s
constant. Reading a flag pins the calling route to the lowest
revalidate among its fetches, and the footer links live in the root
layout, so a naive gate there would have dropped every school and
place page from a weekly cache to a 5-minute one. The layout passes
604800, the floor those routes already declare, and the build confirms
all four SSG route families still prerender. The cost is one-way
latency: pages follow a flip in minutes, footer links within a week.

The e2e journeys follow the existing paired shape from the
admission_distance flag: a lit journey and a dark one for each flag,
reading state from whether /about and /blog respond rather than from
/api/flags, which another journey asserts is not publicly reachable.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DXnXQKnPpZBBP61fBQiFkq
2026-09-08 17:12:25 +01:00

4.7 KiB

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.

Before any of this: the flags

/blog and /about are behind feature flags (blog and about_page), and every flag in this system starts off. While blog is dark, /blog, every post page and the RSS feed return 404, and neither appears in the sitemap or the footer. Posts still save normally, because /admin is deliberately not flagged: you have to be able to write a post before there is anything worth switching on.

So a new post published to a dark blog is invisible, and that is working as intended, not a bug. Flip the flag in Unleash when the content is ready. Staging and production hold their own values (development and production environments), so you can light it on staging first. The page follows a flip within five minutes; the footer link takes up to a week, because it renders in the root layout and is cached at the same weekly floor as the school corpus.

Both flags are temporary scaffolding, like every flag here: a test starts failing once one is older than 90 days, at which point either the feature is permanent and the flag comes out, or it was never going to ship.

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:

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 <h1> 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.
  • No em dashes. They are one of the clearest tells of machine-written prose, which is the whole problem this blog exists to fix. A full stop, a colon, a semicolon or a pair of commas does the job and reads as though a person chose it.
  • 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.