DEV Community

Aulvem
Aulvem

Posted on

How I run a small blog on Astro 5 + Content Collections

I run a small blog (aulvem.com) on Astro 5 + MDX + Content Collections, hosted statically on Cloudflare Pages. The interesting part isn't the stack — it's the operational rules I lean on the schema to enforce so that a writer (me) can't ship something half-broken.

This post is a short tour of that setup: which packages I keep, which dependencies I deliberately don't add, and the three build-time checks that hold the writing flow together.

The stack, in eight runtime packages

// package.json (runtime deps, abridged)
{
  "astro": "^5",
  "@astrojs/mdx": "^4",
  "@astrojs/sitemap": "^3",
  "@astrojs/rss": "^4",
  "@astrojs/tailwind": "^6",
  "rehype-external-links": "^3",
  "rehype-mermaid": "^3",
  "tailwindcss": "^3.4"
}
Enter fullscreen mode Exit fullscreen mode

Dev deps: pagefind (full-text search), sharp (local image processing), playwright (build-time SVG render for mermaid), typescript, @types/node. No React. No Vue. No Vite plugins.

The rule I started with: don't add a dependency on the hope it'll be useful later. Anything that doesn't have a written-down use case stays out.

Three build-time rules

1. category: reviewsaffiliate: true

A post in the reviews category is, by ad-network rules, advertising. It has to carry a disclosure banner and rel="sponsored" on outbound links. Both of those are injected by rehype plugins gated on affiliate: true. So the worst-case failure mode is publishing a review post with affiliate left at its default — disclosure missing, sponsored rel missing.

The Zod schema couples them with one .refine():

.refine((data) => (data.category === "reviews") === data.affiliate, {
  message: "affiliate must be true iff category is 'reviews'",
  path: ["affiliate"],
})
Enter fullscreen mode Exit fullscreen mode

Both sides compared with ===. Change only one and the build fails.

2. Typed structured data in frontmatter

HowTo and FAQPage JSON-LD blocks pull from frontmatter rather than from parsed body text. Reasons:

  • Heading renames don't quietly break JSON-LD
  • Zod validates the shape, so missing answer fields are caught at build
  • The JSON-LD generator can trust frontmatter without touching MDX
faq:
  - question: "Why Astro 5?"
    answer: "Markdown-centric, static-only output, schema-typed frontmatter, small core deps. Astro 5 fits all four."
Enter fullscreen mode Exit fullscreen mode

3. lastmod from frontmatter

Astro's official sitemap integration doesn't read updatedDate from MDX frontmatter. Default lastmod is the build time, which broadcasts "every post updated on every build" to search engines and AI search.

I walk the collection at build time and pipe updatedDate ?? pubDate into the sitemap entry as lastmod. Paginated noindex pages get dropped from the sitemap in the same pass — submitting them through the sitemap is a contradictory signal otherwise.

Single source of truth for operational flow

Adding a post, retiring a post, updating product pages — all of these are anchored in one doc (docs/content-flow.md), and the scaffolding scripts pull from it. Same rule every time. That absorbs most of the "the approach drifts run to run" variance, which is the failure mode that gets me when I haven't touched the project in a few weeks.


The full version with the decision history, what I dropped, and where Zod can't reach lives on Aulvem → How this blog is built — Aulvem on Astro 5 and Content Collections

Top comments (0)