engineering
Designing a content model that cannot break
Part one of building this blog — a Zod-validated content schema that makes broken articles impossible to publish, not merely discouraged.
This series documents building the site you are reading, one decision at a time. The first decision — before layout, before a single color — was the content model, and the principle behind it: every editorial rule that can be enforced by the compiler should be. Discipline is a finite resource. Schemas are not.
Rules as types, not reminders
A blog accumulates editorial rules: descriptions must fit a search snippet, every image needs alt text, series parts need an order. Enforced by memory, each rule holds until a tired evening. Enforced by schema, each rule holds forever, because violating it fails the build with a filename and a field.
Astro’s content collections make this nearly free — articles are Markdown files, and each one’s frontmatter is validated against a Zod schema at build time. The interesting part is what the bounds encode:
const blog = defineCollection({
loader: glob({ pattern: '**/*.{md,mdx}', base: './src/content/blog' }),
schema: ({ image }) =>
z.object({
title: z.string().min(10).max(70), // fits a search result
description: z.string().min(50).max(155), // fits a meta description
pubDate: z.coerce.date(),
topic: reference('topics'), // must actually exist
tags: z.array(z.string()).max(6),
heroImage: image().optional(),
heroAlt: z.string().min(5).optional(),
draft: z.boolean().default(true), // safe by default
})
.refine((d) => !d.heroImage || !!d.heroAlt, {
message: 'heroAlt is required when heroImage is set',
}),
});
That description bound is not a style preference. It is an SEO requirement — the length a search engine will actually display — promoted into a type. It is now structurally impossible for me to publish an article with a missing or truncated meta description. The refinement below it does the same for accessibility: a hero image without alt text is not a warning, it is a build failure.
Three choices worth stealing
draft: true by default. A new file is a draft until explicitly told otherwise. The failure mode this prevents — half-written thoughts going live because a default leaned the wrong way — is rare, but its cost is public.
References, not strings. topic is a reference to a real topics collection, so a typo like enginering fails the build instead of silently creating an orphan page. Referential integrity is not just for databases.
One query helper, used everywhere. Every page pulls articles through a single function that filters drafts and future-dated posts. Filtering inline at each call site is how a draft eventually leaks into a feed — centralizing it is the boring, obvious choice, which is exactly why it works.
What this buys at write time
The payoff arrives every publishing day. Writing happens in any editor, against a schema that catches mistakes at build rather than after deploy. There is no admin panel to maintain and no server behind it — the whole CMS is a folder of files and a contract.
With the model fixed, the next problem is making the site discoverable: titles, canonicals, structured data, feeds — the invisible layer that decides whether anyone finds the writing. That is part two.
One essay on building for the web, most weeks
No digests, no product updates — the same articles that appear here, in your inbox. Unsubscribe with one click, any time. Or take the RSS feed instead.
Double opt-in — you'll get a confirmation email first. Your address is used for nothing else.