Frontmatter Validation for Markdown Content
TL;DR
- Validate YAML frontmatter with Zod so broken content fails fast in hooks/CI.
- Require quoted
YYYY-MM-DDdates to avoid YAML parsing them asDateobjects.- 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
titleordescription - Invalid date formats (e.g.,
2024-1-5instead of2024-01-05) - Unquoted dates that YAML interprets as
Dateobjects 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:
Key design decisions:
gray-matterfor parsing - A battle-tested library that extracts frontmatter from markdown files- Zod for validation - Type-safe schema validation with excellent error messages
.strict()mode - Catches unexpected fields (typos, leftover metadata)- 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:
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:
- Type inconsistency - Sometimes you get a string, sometimes a
Date - Timezone issues -
Dateobjects can shift dates when converted back to strings - Parsing ambiguity -
2024-01-02becomes aDate, butJan 2, 2024stays a string
The schema enforces strings with a custom error message:
When validation fails, you see:
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.
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:
Run it manually with:
Git Hook (Pre-Push)
Validation runs automatically before every push via Husky. The .husky/pre-push hook contains:
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:
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:
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:
Adding New Fields
To extend the schemas with new frontmatter fields:
- Add the field to
frontmatterFieldsinvalidate-frontmatter.mjs. - Choose whether it is required for published content.
- Let
DraftFrontmatterSchema.partial()make it optional for drafts, or define a draft-specific constraint when necessary. - 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.