Contents15

This post is the starting point for documenting how Aulvem is built — not as a stack tour, but as a map of what we enforce through the schema and what we deliberately leave out. The stack itself is just Astro + MDX + Content Collections — nothing special there. What I want to write about is the design work on top of that: how operational rules a writer tends to forget get pushed into build-time checks.

The overview hub goes here. The individual decisions (schema enforcement, structured-data parity, custom sitemap lastmod) are split into follow-up posts.

Stack overview — Astro 5 + MDX + Content Collections + R2

Aulvem runs on Astro 5 (static output) + MDX + Content Collections + Tailwind 3 + Pagefind + Cloudflare R2. There’s no dynamic server — the site is just pre-built HTML served from Cloudflare Pages.

I narrowed the selection criteria to three points:

  • the build output is fully static
  • post metadata can be locked down by a schema
  • the core has a small dependency tree

Astro 5 met all three. The other features — partial hydration, UI framework integration, and so on — aren’t being used right now.

Whether Astro is the right pick depends on the requirements. If you want to layer on dynamic auth, comments, or subscriptions, something like Next.js is a better fit.

Definition: what Content Collections is

Aulvem defines two collections: blog (posts) and services (product pages). The schema lives in a single file and is validated at build time for both collections.

Full stack — the main packages

Runtime dependencies are kept to eight packages.

PackageUseNotes
astro ^5SSG corestatic output only
@astrojs/mdx ^4MDX supportall posts are .mdx
@astrojs/sitemap ^3sitemap generationlastmod injected manually
@astrojs/rss ^4RSS generationfull content:encoded
@astrojs/tailwind ^6Tailwind integrationapplyBaseStyles: false
rehype-external-links ^3rel attributes on external linksnoopener noreferrer
rehype-mermaid ^3Build-time mermaid → SVGinline-svg strategy
tailwindcss ^3.4stylingholding off on v4

On the dev side: pagefind (full-text search), sharp (local image processing), playwright (build-time SVG rendering for mermaid), typescript, and @types/node. No React, no Vue, no Vite plugins.

The opening rule is: don’t add a dependency on the hope it’ll be useful later. Unused dependencies show up in both build time and security-alert noise, so before adding one I check whether the use case is articulable in a sentence.

Three pieces I wouldn’t drop

1. Enforce rules through the schema

Operational rules don’t live in the README — they live in the frontmatter schema. I wanted the build to fail when a writer (including me) forgets, instead of leaning on memory.

For example, category: reviews posts have to be flagged as advertising under affiliate-network rules, so I require affiliate: true on them. Instead of leaving this as a line in the README, Zod’s .refine() forces category === "reviews" and affiliate === true to always be equivalent. Changing only one side breaks the build, so memory isn’t the load-bearing piece.

The same idea applies to the howto / faq structured data, but that belongs in a follow-up post.

2. Keep structured data and body text in sync

The JSON-LD (howto / faq) content is generated from the frontmatter, but the same content must also appear in the body. This avoids Google’s structured-data mismatch penalty — pumping out elaborate JSON-LD that has no corresponding body text violates Google’s quality guidelines, and getting caught costs you the structured-data display eligibility.

Aulvem’s rule is to write the same content on both sides — frontmatter and body — so a one-sided update can’t slip through. That alone closes off the failure mode where you fill in the frontmatter FAQ and forget the body.

3. Read sitemap lastmod from frontmatter yourself

Astro’s official sitemap integration doesn’t read updatedDate from MDX frontmatter. So Aulvem walks every MDX file with custom logic and pipes updatedDate ?? pubDate into the sitemap as lastmod.

Without that, the sitemap lastmod defaults to the build time, which broadcasts the noise signal that “every post was updated every build” to search engines. lastmod is also used as a freshness input by AI search (citation needed: confirmed in both Google’s and Bing’s docs), so it’s not a part to be sloppy on.

At the same time, paginated pages that return noindex, follow are dropped from the sitemap. Submitting a noindex URL through the sitemap is a contradictory signal, so both have to be handled together.

How the three pieces connect

flowchart LR
  FM[frontmatter] --> Zod
  FM --> Sitemap
  FM --> Sync
  Body[MDX body] --> Sync

  Zod["Zod schema check<br/>category ⇔ affiliate"] --> Build
  Sitemap["sitemap lastmod injection<br/>reads updatedDate"] --> Build
  Sync["frontmatter ↔ body<br/>parity"] --> Build

  Build[Astro build]

  Build --> HTML[HTML + JSON-LD + SVG]
  Build --> SM[sitemap.xml]

Two inputs — frontmatter and body — fan out through three build-time pieces and converge at Astro build. Frontmatter runs Zod schema validation and sitemap lastmod injection, while both frontmatter and body feed the parity check that makes sure the same content lives on both sides.

All results are unified by Astro build into the HTML, JSON-LD, and sitemap.xml. If any single piece fails, the build fails — so polishing one side alone won’t get you to publish.

Operational flow lives in a single source of truth

Operational flows — adding a post, adding a product, retiring a post — are anchored in one doc as the single source of truth. From there, the scaffolding and integrity-check scripts are wired in, and the rule is to follow the same steps every time. I think this absorbs most of the “the approach drifts run to run” kind of variance. What’s left is the warmth of the prose, which automation shouldn’t try to normalize anyway.

There’s also a rule: don’t bump updatedDate just because you added a preface note — bump it only on substantive revision. Search engines and AI search read dateModified as a freshness signal, so handling it loosely lowers the whole site’s authority. The criteria are fixed up front.

FAQ

Why Astro 5?

I narrowed it down to four constraints — Markdown-centric, fully static output, schema-typed frontmatter, and a small core dependency tree. Astro 5 was the best fit. Content Collections and MDX are built in, so there’s no need for extra plugins to get typed content.

Why no headless CMS (Sanity, Contentful, etc.)?

Publishing once or twice a week with one editor, MDX + git is faster than a CMS admin UI. A CMS adds the cost of draft locations, schema migrations, and API auth from day one. I’d revisit this when readership crosses ~10k, or when more editors come in.

How is full-text search implemented?

Pagefind runs after astro build and produces a static binary search index. The front end pulls it through a tens-of-KB WASM loader. No serverless function, no external API.

Does i18n use Astro’s built-in support as-is?

Routing uses Astro 5’s i18n (defaultLocale: en / prefixDefaultLocale: false) untouched. hreflang and sitemap lastmod, on the other hand, are read out of the MDX frontmatter through a custom helper. The details belong in a follow-up post.

Wrap-up

That’s the overview. Building and running this, what mattered most wasn’t which stack to pick — it was deciding how tightly to lock the frontmatter down with the schema.