Guide
Schemas Before Content: A Practical Guide
How to design Zod schemas for marketing content so that authoring stays fast, types stay honest, and the build catches mistakes before readers do.
Most marketing platforms treat content as a bag of strings until something breaks in production. We do the opposite: every content type is a Zod schema, validated at build, with a single canonical shape. This guide walks through how we got there and what it costs.
Why schemas first
The promise of Markdown-in-git is freedom. The risk is drift. Without a schema, every author makes a slightly different choice about whether a field is tags or tag_list, whether dates are ISO strings or human strings, whether draft is a boolean or the string "true". Six months in, half the build is normalization.
Defining the schema first inverts the burden. The schema becomes the API contract between authors (human and AI) and the templates that consume the content. The build refuses to ship a malformed file.
The minimum viable schema
For a blog post, the bare minimum is:
- A title.
- A description (also used for OG meta and the index card).
- A publish date that coerces to a real
Date. - A draft flag, defaulted to
false. - An author string.
Everything else — tags, hero image, reading time — is optional. The instinct to add fields “in case we need them” is exactly what makes schemas brittle. Add them when the template actually consumes them.
Coercion is your friend
Authors will type 2026-04-01. Authors will also type 2026-04-01T00:00:00Z if they’re feeling fancy. Both should work. Zod’s z.coerce.date() handles this without ceremony. The same goes for booleans (z.coerce.boolean() for environments where YAML coerces unevenly).
The rule we follow: if the surface form is ambiguous but the intent is unambiguous, coerce. If the intent itself is ambiguous, fail loudly.
What gets validated, where
- Frontmatter: Zod schema at build time. Nothing reaches a template that hasn’t passed the schema.
- Body MDX: a separate allowlist lint, because MDX can import arbitrary components and that’s a security risk.
- JSON-LD: never
set:htmlfrom frontmatter values. We render structured data through helpers that take typed inputs.
When the schema needs to change
It will. New campaigns want new fields. Old fields stop earning their keep. We treat schema changes as ordinary PRs:
- Add or modify the field in
packages/schemas. - Update consumers (templates, sitemap, llms.txt).
- Migrate existing content in the same PR.
- Ship.
The migration is the expensive step, and it’s why we keep the schema small. Every optional field is a future migration we haven’t paid for yet.
What this guide doesn’t cover
- Rich content models (case studies with embedded testimonials, etc.). The same principles apply, but the schemas are larger and the migrations more involved.
- Cross-collection references. Astro’s Content Layer has a
reference()helper; we have opinions about when it’s worth the indirection.
Both are topics for a follow-up.
- #schemas
- #zod
- #authoring
- #platform
Want more like this?
Subscribe and we'll send new guides as we publish them — no list-trading, no fluff.