
Guide
Use a content registry instead of a CMS
Keep one typed array of post metadata and derive the index, sitemap, feed, and schema from it. Publishing becomes a one-word change and nothing drifts out of sync.
Published July 29, 2026
Keep one typed array of post metadata and derive everything from it: the index, the sitemap, the RSS feed, the structured data, and whether a page is indexable. Publishing becomes a one-word change, nothing can drift out of sync, and the build refuses to compile if a page is missing its entry.
The problem this solves
Adding an article to a hand-rolled blog usually means touching four places: the page itself, the index that lists it, the sitemap, and the feed. Miss one and the failure is silent — a post nobody can find from the index, or a sitemap entry pointing at a page that was renamed three weeks ago. Nothing errors. It just quietly stops being true.
The fix is to stop writing those four things and derive them. One array holds the metadata; every surface that needs to know about posts reads from it. There is no synchronisation step because there is nothing to synchronise.
The registry
A post is a .tsx page plus one entry here. The type is doing real work: a missing field is a compile error, and cluster is keyed off the actual hub map, so an invented category cannot be typed.
export interface Post {
slug: string
title: string
/** Meta description, ~150 chars, answer-first */
description: string
type: PostType
status: PostStatus
/** ISO date (YYYY-MM-DD) */
datePublished: string
/** ISO date — bump only with real content edits */
dateModified: string
/** The query this post is written to win — the measurement ledger */
targetQuery: string
cluster: PostCluster
/** Slugs of related posts for sideways internal links */
related: string[]
/** 1600×900 illustration. `alt` describes the photo, not the article. */
image: { src: string; alt: string }
}Two fields are there for discipline rather than rendering. targetQuery is the measurement ledger — the query each post was written to win, compared against Search Console later, so the claim is on record before the result is known. And dateModified carries a rule in its own comment: it moves only for real edits, never to fake freshness.
Publishing is one word
Status is the only lever. A draft renders at its real URL so it can be reviewed in place, but it is noindexed, kept out of the index listing, and excluded from the sitemap and the feed. Flipping it to published puts it in all four at once.
export function buildPostMetadata(slug: string): Metadata {
const post = getPost(slug)
return {
alternates: { canonical: postPath(post) },
title: serpTitle(post.title),
description: post.description,
...(post.status === "draft" && { robots: { index: false, follow: false } }),
// …OpenGraph and Twitter cards, also derived from the entry
}
}The index page applies the same idea to itself. Before the first post exists there is nothing worth indexing, so it noindexes until the registry says otherwise — and stops the moment it has content. Nobody has to remember to remove it.
// Self-healing: the index stays noindexed until the first post publishes
...(publishedPosts.length === 0 && { robots: { index: false, follow: false } }),Everything downstream is derived
The sitemap maps over published posts and takes each lastModified from the entry's own dateModified — real dates, not a build timestamp that claims every page changed on every deploy.
...publishedPosts.map((post) => ({
url: postUrl(post),
lastModified: new Date(`${post.dateModified}T12:00:00Z`),
changeFrequency: 'monthly' as const,
priority: 0.7,
})),The RSS feed maps over the same array. So does the schema builder, which produces TechArticle for build notes and BlogPosting for everything else off the type field — one more thing nobody has to remember, alongside generating FAQ markup from the array being rendered.
The guard that has earned its keep
The lookup throws rather than returning undefined. That single line converts the whole class of "page exists but nothing knows about it" bugs into a build failure with the slug in the message.
export function getPost(slug: string): Post {
const post = posts.find((p) => p.slug === slug)
if (!post) {
throw new Error(
`No entry in data/posts.ts for slug "${slug}" — add one before creating the page.`
)
}
return post
}It fires more often than you would guess. This repo gets edited by parallel sessions, and on a single working day it caught three separate pages that had been created without a registry entry — each one a post that would have shipped invisible to the index, the sitemap, and the feed. A loud build failure is a much better outcome than a quiet one, and it costs four lines.
When this is the wrong answer
The moment somebody who does not deploy needs to publish. Everything good about this design comes from the content living in the repo, and that is exactly what makes it unusable for a marketing team on a Friday afternoon. The honest recommendation then is a CMS — very possibly a headless one, so the editing changes and the front end does not.
It is also the wrong answer if posts are mostly prose with no bespoke structure — at that point components are ceremony and Markdown is enough. The registry idea still applies; only the page format changes.
The transferable part
Derive, then guard. Any time the same fact is written in more than one place, one copy will eventually be wrong, and the version that rots is always the one nobody looks at — the sitemap, the feed, the structured data. Pick the source of truth deliberately, generate the rest from it, and make the build fail when something exists outside the system.
That is the same instinct behind enforcing a JavaScript budget from the build output: make correctness something the toolchain checks, not something a person is supposed to remember.
Whether the answer is a registry, a headless CMS, or something in between is a scoping question, and it is one of the first settled on a build.
Questions
Why not MDX files with frontmatter?
Frontmatter is the same idea with weaker guarantees: a typo in a field name is a runtime surprise rather than a compile error, and there's no type to tell you a required field is missing. If the posts are components anyway — tables, decision records, code blocks — a typed registry beside them costs nothing extra and checks itself.
Doesn't this mean a developer has to publish every post?
Yes, and that's the whole trade. It suits a site where the person writing is the person deploying. The moment non-technical people need to publish without a deploy, this is the wrong architecture and a CMS is the right one — headless, so the front end doesn't change.
What happens when the registry gets long?
Nothing, so far — it's a flat array and the build reads it once. Thirty-odd entries is a file you scroll. If it ever became unwieldy the split would be by cluster into separate arrays, still typed and still concatenated into one export, because the value is in there being exactly one list.
How do you preview a post before it's live?
Drafts render at their real URL — noindexed, unlisted, and badged so nobody mistakes one for published — and appear in the index only in development. You review the actual page rather than a preview mode that renders slightly differently from production.
Related notes
- Structure a repo so AI agents need less prompting
- Retire a page without losing its SEO
- Link your schema with @id instead of repeating it
More on ai integration
- Extract action items from Notion with Claude
- SEO reports from the CLI, sized for AI agents
- A local dev environment an AI agent can verify in
- Reusable prompts for a known tech stack
Related service: AI Integration
Want this kind of engineering on your project?
Tall Karol takes on fractional and project-based engagements for startups and agencies.
Book a working session