AstroTypeScriptDeveloper Experience (DX)Static Architecture

Invalid Content Should Fail the Build

Static Signal
A brass gatehouse in a fog-filled foundry where glowing molten content pours through a precision-cut stencil plate, shapes that match passing cleanly into the press beyond while mismatched ones strike the plate and burst into cooling sparks, dark steampunk hall lit by neon teal and amber with copper circuitry threading the gate

You publish a post. The build is green. The deploy is green. Two weeks later you notice the post never appeared on its category page, because the frontmatter says web-perfomance and no such category exists.

Nothing errored. The category filter matched zero posts, the template rendered an empty list, and the empty list looked exactly like a category that happens to have no posts yet.

This is the failure mode of markdown-driven sites, and it is not really about typos. It is that frontmatter is a structured API surface with no type checker in front of it. Every field is a string in a text file, consumed by templates that assume it is well-formed. The gap between “the build succeeded” and “the content is correct” is where these bugs live.

Close the gap. A build that succeeds should mean the content is publishable.


Frontmatter is an untyped API

Look at what a typical post header actually promises the rest of the codebase:

---
title: 'The Trailing Slash Is Not Cosmetic'
publishedAt: 'Fri Aug 14 2026 09:08:17 GMT-0700 (Pacific Daylight Time)'
categories:
  - 'static-html'
draft: false
---

Four fields, four implicit contracts:

  • title exists and is a string — your <title> tag depends on it
  • publishedAt parses as a date — your sort order depends on it
  • categories contains slugs that exist somewhere else — your taxonomy pages depend on it
  • draft is a boolean, not the string "false", which is truthy

Break any one of them and you get silent wrongness rather than a crash. A missing title renders an empty heading. An unparseable date sorts to the top or bottom of the index. draft: "false" publishes nothing — or worse, hides a post you thought you shipped.

YAML is especially good at this kind of betrayal. draft: no is a boolean. slug: 1.10 is a number. A date without quotes may become a Date, a string, or a parse error depending on your loader. None of these produce an error at the point the mistake was made.


The schema is eight lines of Zod

Astro’s content collections take a schema, and the schema runs during astro build. A violation is a build failure with the offending file and field named:

import { defineCollection, z } from 'astro:content'
import { glob } from 'astro/loaders'

const posts = defineCollection({
  loader: glob({ pattern: '**/*.md', base: './content/posts' }),
  schema: z.object({
    title: z.string(),
    slug: z.string(),
    excerpt: z.string().max(160),
    publishedAt: z.coerce.date(),
    heroImage: z.string().optional(),
    author: z.string().optional(),
    categories: z.array(z.string()).optional(),
    draft: z.boolean().optional().default(false),
  }),
})

export const collections = { posts }

That is not a lot of code, and it buys three distinct things.

It fails at the right time. The error surfaces in npm run build on the machine of whoever wrote the post, not in a bug report a month later.

It coerces once, at the boundary. z.coerce.date() means publishedAt is a real Date everywhere downstream. No template parses a string. No sort comparator does new Date(a.data.publishedAt) defensively because it isn’t sure. Type coercion belongs at the edge of the system exactly like it does in an HTTP handler.

It types the consumers. The inferred type flows into every getCollection('posts') call, so post.data.publishedAt.getFullYear() typechecks and post.data.titel does not. Run astro check in CI and the templates are covered too.

The .max(160) on excerpt is the underrated one. That field becomes your <meta name="description"> and your card text. Search engines truncate around 155–160 characters, and a card with clamped overflow will hide the ending without telling you. The constraint is a real rendering requirement, so it belongs in the schema rather than in a style guide nobody re-reads.


Referential integrity without a database

The category typo from the opening is not a type error. 'web-perfomance' is a perfectly good string. It is a broken reference — the kind of thing a CMS charges you for under the name “relation field.”

You can enforce it against a JSON file in your repo:

import categoriesData from '../content/categories.json'

const categorySlugs = new Set(categoriesData.map((c) => c.slug))

const categories = z
  .array(z.string())
  .optional()
  .superRefine((slugs, ctx) => {
    for (const slug of slugs ?? []) {
      if (!categorySlugs.has(slug)) {
        ctx.addIssue({
          code: 'custom',
          message: `Unknown category "${slug}" — must match a slug in content/categories.json`,
        })
      }
    }
  })

Now web-perfomance stops the build with the file path, the field, and the reason. The taxonomy is closed: you cannot invent a category by misspelling one, and renaming a category surfaces every post that still points at the old slug.

The same pattern covers any cross-reference in your content — relatedPosts slugs that must resolve to real posts, author IDs that must exist in an authors file, tags drawn from a controlled vocabulary. Every one of those is a foreign key, and a Set lookup in a build script is a perfectly serious way to enforce one when your entire dataset fits in memory.

This is the honest version of the headless CMS pitch. What a CMS sells is not storage — it’s the schema, the required fields, and the relation picker. You can have all three in your repo, in version control, running in CI, without an API token.


What the schema cannot see

A schema validates the frontmatter as data. It knows nothing about the filesystem, and that is where the second class of bugs lives:

  • heroImage: 'my-post.webp' is a valid string whether or not public/media/my-post.webp exists. The build passes; the card renders a broken image.
  • slug in the frontmatter disagreeing with the filename, so the URL and the internal references diverge.
  • A hero image that is technically present but 2.4 MB, quietly making the post the slowest page on the site.
  • A publishedAt copied from the template and left at a placeholder date.

None of these are type errors. All of them are mechanical, so they belong in a mechanical gate — a short script you run alongside the build:

import { readFile, stat } from 'node:fs/promises'
import { join, basename } from 'node:path'
import matter from 'gray-matter'

const file = `${slug}.md`
const { data } = matter(await readFile(join('content/posts', file), 'utf8'))
const errors = []

if (data.heroImage) {
  const image = join('public/media', data.heroImage)
  try {
    const { size } = await stat(image)
    if (size > 220_000) {
      errors.push(`hero image is ${Math.round(size / 1024)}KB — over budget`)
    }
  } catch {
    errors.push(`heroImage "${data.heroImage}" does not exist in public/media`)
  }
}

if (data.slug !== basename(file, '.md')) {
  errors.push(`slug "${data.slug}" does not match filename "${file}"`)
}

if (errors.length) {
  for (const e of errors) console.error(`✗ ${e}`)
  process.exit(1)
}

Fifty lines of this replaces the entire class of “the post looked fine locally” incidents. It is also the part people skip, because it feels less principled than a schema. It isn’t — it is the same idea applied to facts that live outside the file.


Draw the line at “does this break”

The failure mode on the other side is a validator that has opinions. Resist it.

Fail the build on anything that renders wrong, routes wrong, or ships broken: missing required fields, unresolvable references, absent assets, values that overflow a hard limit like a meta description.

Warn on things that are probably wrong but occasionally intentional: a post with no relatedPosts, an unusually short body, a hero image whose dimensions are off-spec but still usable.

Say nothing about prose. Reading level, heading counts, keyword density — these are editorial judgments, and encoding them into a gate means the first time you want to break the pattern you disable the gate entirely. A validator you are tempted to bypass has already stopped working.

The test for which bucket a rule belongs in: if it fires, would you fix the content or add an exception? Rules that produce exceptions should be warnings.


You do not need Astro for this

The pattern is not framework-specific. Any static build that reads markdown can do the same thing in about thirty lines:

import { z } from 'zod'
import matter from 'gray-matter'
import { readdir, readFile } from 'node:fs/promises'

const schema = z.object({
  title: z.string(),
  excerpt: z.string().max(160),
  publishedAt: z.coerce.date(),
  draft: z.boolean().default(false),
})

const files = (await readdir('content/posts')).filter((f) => f.endsWith('.md'))
const failures = []

for (const file of files) {
  const { data } = matter(await readFile(`content/posts/${file}`, 'utf8'))
  const result = schema.safeParse(data)
  if (!result.success) {
    for (const issue of result.error.issues) {
      failures.push(`${file}: ${issue.path.join('.')} — ${issue.message}`)
    }
  }
}

if (failures.length) {
  console.error(failures.join('\n'))
  process.exit(1)
}

Wire it into prebuild and it runs on every build, local and CI, whether the site is Eleventy, Next.js static export, Hugo with a Node step, or a pile of scripts. The framework decides how convenient this is. It does not decide whether it is possible.


The point is what green means

Every static site already has a strong guarantee available to it: the content and the code are validated together, at build time, before anything reaches a user. Most sites throw that guarantee away by treating frontmatter as free-form text and finding out about mistakes from analytics.

A schema plus a mechanical gate is maybe a hundred lines total. What you get back is a build signal that actually means something — not “the templates compiled,” but “this content is well-formed, its references resolve, its assets exist, and it is safe to publish.”

That is the whole argument for doing content in a repo instead of a database. If you are not enforcing it at build time, you are running a CMS with none of the validation and all of the discipline required.