Web Engineering

Astro content is a real interface

5 min read

Astro content collections look simple until you trace what depends on them.

There is a folder of Markdown and MDX files. Each file has frontmatter. Astro reads the collection, validates the shape, and turns the result into routes, feeds, archives, search results, and anything else that wants to talk to the posts. That feels lightweight when the site is small. It is still an interface.

The thing that changed my mind was noticing how many other pieces lean on that shape. The archive page leans on it. The RSS feed leans on it. Search indexes lean on it. Related-post logic leans on it. Any script that edits content leans on it too, whether it says so or not.

frontmatter is not decoration

The frontmatter at the top of a post looks like metadata. In practice it behaves like application data.

This repo’s blog collection expects fields like title, excerpt, category, author, tags, publishDate, and a few optional runtime flags for heavier pages:

z.object({
  title: z.string(),
  excerpt: z.string(),
  category: z.string().trim(),
  author: z.string().trim(),
  draft: z.boolean().optional(),
  tags: z.array(z.string()),
  image: image().optional(),
  publishDate: z.string().transform((str) => new Date(str)),
  load_pyodide: z.boolean().optional(),
  load_mathjax: z.boolean().optional(),
})

That schema is quietly doing a lot of work. It keeps draft state from turning into a magic string. It keeps tags structured instead of comma-separated noise. It keeps heavy runtime features opt-in. It keeps the site from assuming that every post is the same kind of page.

The useful habit is to treat each field as something the rest of the site actually consumes.

excerpt is the promise shown in the archive and feed reader. category is how readers and scripts group posts. publishDate controls ordering and gives the piece a place in the record. load_pyodide and load_mathjax are not style choices; they change the page payload. If a post needs those runtimes, it should ask for them on purpose.

the slug is part of the product

A filename is not just a filesystem detail.

astro-content-is-a-real-interface.mdx becomes a route, a search key, a link target, and a stable identifier for editors and agents. It will show up in browser history, analytics, feeds, and maybe screenshots. If the slug is vague or clever in the wrong way, every one of those surfaces gets a little harder to use.

I prefer filenames that survive long enough to be boring:

approval-buttons-need-context.mdx
queues-make-ai-workflows-calmer.mdx
small-sites-need-smoke-tests-too.mdx

They are not trying to win anything. They are just durable names. If a future cleanup script or content agent has to touch the post, a clear slug keeps it from wandering around the archive like it forgot why it opened the file.

That matters because personal sites age into archives quickly. The route is one of the few labels that keeps the archive navigable.

mdx widens the contract

Markdown is pretty small. MDX is not.

Once a post can import a component, mount an island, require a runtime flag, or depend on a client-only library, the article stops being plain prose and starts behaving like a small app. The frontmatter schema still validates the shape of the post, but it does not prove that the interactive part is correct, visible, accessible, or fast.

That is why I do not like treating MDX as “Markdown with widgets.” It is source code living inside the content folder.

If a post imports an interactive component, the dependency should be obvious. If it needs MathJax, the flag should be present. If it uses Pyodide, the article should justify the heavier page with something the reader can actually do. If an island breaks, the page should not collapse into a blank section with no explanation.

The contract around a post has to include the boring things:

  • what the article loads
  • whether the component props are stable
  • what happens if the component fails
  • whether the page still builds in production
  • whether the example still matches the prose

That is a lot of responsibility for a content file, which is exactly why it matters.

the checks should fail early

Build checks are editorial tools. I want them to catch mistakes while the change is still cheap.

A missing frontmatter field should fail before the archive quietly sorts wrong. A malformed date should fail before the post appears in the wrong place. A renamed MDX import should fail before deploy. Markdown lint should catch the small consistency problems that are annoying to discover after the fact.

The site breaks in ordinary ways:

  • title copied from another post
  • excerpt left generic
  • draft flag forgotten
  • category typo creates a new bucket
  • date string parses differently than expected
  • imported component renamed
  • code fence left unclosed
  • image path works locally by accident

None of that is dramatic. All of it is easy to catch if the content system treats the schema as a real interface.

That becomes more important the moment an agent edits the files. A model can generate frontmatter that looks reasonable and is quietly wrong. The schema is the first reviewer that does not care how confident the prose sounds.

This is why I like Astro’s content layer so much. It keeps the writing close to the filesystem, but it refuses to pretend that the files are inert. The files have consumers. The consumers need a contract. The contract should stay boring enough to hold up when the site gets bigger than the person writing it.

Jeremy London

About Jeremy London

Engineering leader and builder in Denver. I write about AI platforms, agents, security, reliability, homelab infrastructure, and the parts of engineering work that have to survive production.