Carles Andres' avatarHomeBlogReference
Back to reference

Frontmatter Validation for Markdown Content

Updated on 8/31/2026
This is a reference article.
Reference articles are written with the help of AI agents, after we have managed to solve a problem.

TL;DR

  • Validate YAML frontmatter with Zod so broken content fails fast in hooks/CI.
  • Require quoted YYYY-MM-DD dates to avoid YAML parsing them as Date objects.
  • Use file location to distinguish flexible drafts from publishable content.
  • Run validation locally with bun run --cwd apps/web validate-frontmatter.

This site uses Markdown files with YAML frontmatter for published blog posts and reference articles. Drafts may start as plain Markdown and acquire metadata gradually. To prevent malformed content from slipping into production, I implemented location-aware validation using Zod schemas, git hooks, and CI checks.

Why Validate Frontmatter?

YAML frontmatter is powerful but error-prone. Common issues include:

  • Missing required fields like title or description
  • Invalid date formats (e.g., 2024-1-5 instead of 2024-01-05)
  • Unquoted dates that YAML interprets as Date objects instead of strings
  • Typos in field names that go unnoticed until rendering fails
  • Unexpected fields that indicate copy-paste errors

Without validation, these errors surface at build time, runtime, or worse—on the live site.

The Validation Script

The validation logic lives in apps/web/validate-frontmatter.mjs:

javascript
import fs from "node:fs";import path from "node:path";import matter from "gray-matter";import { z } from "zod";
const dateField = z  .string({ message: "must be a quoted string in YAML" })  .regex(/^\d{4}-\d{2}-\d{2}$/, "must be YYYY-MM-DD format");
const PublishedFrontmatterSchema = z  .object({    title: z.string().min(1, "title is required"),    description: z.string().min(1, "description is required"),    published: dateField,    updated: dateField.optional(),    tags: z.array(z.string()).optional(),    aliases: z.array(z.string()).optional(),  })  .strict();
const DraftFrontmatterSchema = PublishedFrontmatterSchema.partial();

Key design decisions:

  1. gray-matter for parsing - A battle-tested library that extracts frontmatter from markdown files
  2. Zod for validation - Type-safe schema validation with excellent error messages
  3. .strict() mode - Catches unexpected fields (typos, leftover metadata)
  4. Location-based state - Root content must be publication-ready, while files under content/drafts/ may be incomplete

Schema Design: The Date Quoting Question

The most interesting schema decision involves date fields. Consider these two YAML approaches:

yaml
# Unquoted (YAML interprets as Date object)published: 2026-02-19
# Quoted (YAML treats as string)published: "2026-02-19"

Why I Require Quoted Dates

YAML 1.1 (used by most parsers including gray-matter) automatically converts unquoted YYYY-MM-DD values to JavaScript Date objects. This causes problems:

  1. Type inconsistency - Sometimes you get a string, sometimes a Date
  2. Timezone issues - Date objects can shift dates when converted back to strings
  3. Parsing ambiguity - 2024-01-02 becomes a Date, but Jan 2, 2024 stays a string

The schema enforces strings with a custom error message:

javascript
const dateField = z  .string({ message: "must be a quoted string in YAML" })  .regex(/^\d{4}-\d{2}-\d{2}$/, "must be YYYY-MM-DD format");

When validation fails, you see:

❌ content/my-post.md   published: must be a quoted string in YAML

The Trade-off

Making quotes mandatory adds friction—contributors must remember the quotes. However, the benefits outweigh this cost:

  • Predictable types everywhere in the codebase
  • No timezone surprises when rendering dates
  • Consistent formatting across all content files

Published and Draft Schemas

Files directly under content/ are publishable. Files under content/drafts/ are not discovered by application routes, feeds, or the sitemap. This makes location the source of truth instead of a draft metadata flag.

FieldPublished contentDraftsWhy
titleRequiredOptionalUsed for page display and metadata
descriptionRequiredOptionalUsed for metadata and discovery
publishedRequiredOptionalControls publication or scheduled discovery
updatedOptionalOptionalRecords a meaningful post-publication update
tagsOptionalOptionalSupports content classification
aliasesOptionalOptionalRecords alternate names

A draft may contain no frontmatter. When a known field is present, the draft schema still validates its type and format. Moving the file to the content root activates the stricter published schema.

Integration Points

Package Script

The validation runs via a dedicated script in apps/web/package.json:

json
{  "scripts": {    "validate-frontmatter": "node validate-frontmatter.mjs"  }}

Run it manually with:

bash
bun run --cwd apps/web validate-frontmatter

Git Hook (Pre-Push)

Validation runs automatically before every push via Husky. The .husky/pre-push hook contains:

sh
#!/usr/bin/env shbun run --cwd apps/web validate-frontmatter

Why pre-push instead of pre-commit?

  • Faster commits - No delay when making quick saves
  • Batch validation - Check everything before pushing to the remote
  • Still catches errors - Problems are caught before they reach the remote

CI Pipeline (GitHub Actions)

The validation also runs in CI as a dedicated step in .github/workflows/ci.yml:

yaml
- name: Validate frontmatter & lint Markdown  run: bun run validate

This serves as a safety net for:

  • Direct pushes that bypass hooks
  • Pull requests from forks
  • CI environments where hooks might not run

Developer Experience

When validation fails, the output is clear and actionable:

❌ apps/web/content/broken-post.md (published)   title: String must contain at least 1 character(s)   published: must be YYYY-MM-DD format
❌ apps/web/content/another-post.md (published)   description: description is required
Frontmatter validation failed.

Each error shows:

  • The file path so you can jump directly to it
  • The field name that failed
  • The reason it failed with human-readable messages

On success:

✓ Validated 26 files.

Adding New Fields

To extend the schemas with new frontmatter fields:

  1. Add the field to frontmatterFields in validate-frontmatter.mjs.
  2. Choose whether it is required for published content.
  3. Let DraftFrontmatterSchema.partial() make it optional for drafts, or define a draft-specific constraint when necessary.
  4. Run validation to ensure existing files comply.

Publication state deliberately does not use a draft field. Moving a file between content/drafts/ and the content root changes its state without duplicating that state in metadata.

Dependencies

The validation script uses two key packages:

  • gray-matter - YAML frontmatter parser
  • zod - TypeScript-first schema validation

Both are included as production dependencies since content is processed at build time.