typescript.
typescript11 min read

Type-Safe MDX Frontmatter with Zod: A Schema-First Content Contract

Zod-validated MDX frontmatter — schema-first content contracts, type narrowing, build-time validation.

Type-Safe MDX Frontmatter with Zod: A Schema-First Content Contract

abstract

I once helped a teammate debug why three of our docs pages had shipped with empty <title> tags for two weeks. The culprit was a YAML typo forty commits earlier, and nothing type-checked because the frontmatter had been parsed into Record<string, unknown> and cast with as Post. Cross about a dozen MDX files or a second author, and that's the failure mode you get by default.

This article walks through a small TypeScript project where every MDX frontmatter block is validated against a Zod schema at parse time. One source of truth for the runtime check and the TypeScript type. I'll add a build-time CLI that walks content/ and fails CI the moment any post breaks the contract, and by the end you'll have a discriminated union that lets you add new post kinds without touching existing render code. Everything runs on Bun 1.1 with three dependencies (zod, gray-matter, TypeScript), and the companion repo has one commit per lesson so you can git checkout any step and run bun test to see the working state.

Step 1: Scaffold the project (commit 97205c1)

Why do most TypeScript starters ship with four different CLIs when one can do the whole job? This scaffold uses just one. Three files, one command. Bun serves as runtime, test runner, and package manager, which cuts the tool budget from four (node, npm, jest, ts-node) to one. Zod handles schema plus inference. gray-matter splits the YAML block from the MDX body.

{
  "name": "mdx-frontmatter-zod-contract",
  "type": "module",
  "scripts": { "validate": "bun run src/validate.ts" },
  "dependencies": { "gray-matter": "4.0.3", "zod": "3.23.8" },
  "devDependencies": { "@types/bun": "1.1.13", "typescript": "5.5.4" }
}

The TypeScript config turns on strict, noUncheckedIndexedAccess, and verbatimModuleSyntax. The first two catch the "I forgot the array might be empty" class of bug that Zod alone can't see. verbatimModuleSyntax forces import type at the call site, which makes it obvious later whether a symbol is a value or a type in a mixed schema-plus-inference file.

export const PROJECT = "mdx-frontmatter-zod-contract" as const;
export const VERSION = "0.1.0" as const;

Run bun install && bun test at this commit. Two tests pass. The tree is trivially runnable, and it'll stay that way at every commit going forward. If you diverge from that discipline in your own project, my rule is that every commit landing on main has to satisfy bun test on a clean clone. It's the cheapest guarantee you can offer future-you, and it's what makes the "checkout any step" pattern in the commit walkthrough actually work.

Step 2: Write the Zod schema (commit 2ceda2f)

What does a content contract look like when the runtime check and the TypeScript type share the exact same source line?

import { z } from "zod";

export const FrontmatterSchema = z
  .object({
    title: z.string().min(1, "title cannot be empty").max(120),
    description: z.string().min(20, "description reads as thin under 20 chars").max(280),
    date: z.preprocess(
      (raw) => (raw instanceof Date ? raw.toISOString().slice(0, 10) : raw),
      z.string().regex(/^\d{4}-\d{2}-\d{2}$/, "date must be YYYY-MM-DD"),
    ),
    keywords: z.array(z.string().min(1)).min(1),
    author: z.string().default("nicedx"),
    draft: z.boolean().default(false),
  })
  .strict();

export type Frontmatter = z.infer<typeof FrontmatterSchema>;

Four points deserve attention.

.strict() rejects unknown fields. If an author types authr: nicedx (typo), the schema fails loudly rather than silently dropping the field and keeping author as its default. Silent field drops are the top reason content contracts feel like they work locally then fail in production.

The date field uses z.preprocess. gray-matter runs js-yaml under the hood, and js-yaml coerces 2026-07-11 (unquoted) into a JavaScript Date object. Without the preprocess, every real-world author has to remember to quote their date strings, and half of them won't. The preprocess normalises Date back to an ISO YYYY-MM-DD string before Zod validates the shape, so authors can write idiomatic YAML.

.default() on author and draft produces a defined value in the output type. Frontmatter["author"] is string, not string | undefined. That's the whole reason to use z.infer instead of writing the type by hand: the schema and the type stay in lockstep even when you add another default a year from now.

Write the message strings inside .min and .regex for content authors, not for engineers. These are the strings they'll actually see in CI. "description reads as thin under 20 chars" beats the default "String must contain at least 20 character(s)" every time.

Step 3: Load and validate a real MDX file (commit 6cfd6e4)

A colleague once shipped a loader like this that threw exceptions, and it took a week of production bugs to convince us the type system had a better idea. This version is about forty lines, and none of them throw. It reads a file, hands the string to gray-matter, runs FrontmatterSchema.safeParse on the resulting frontmatter object, and returns a discriminated result the caller can switch on.

export type LoaderError =
  | { kind: "read_failed"; path: string; cause: string }
  | { kind: "yaml_parse_failed"; path: string; cause: string }
  | { kind: "invalid_frontmatter"; path: string; issues: readonly string[] };

export type LoaderResult = { ok: true; post: Post } | { ok: false; error: LoaderError };

export async function parseMdx(path: string): Promise<LoaderResult> {
  let source: string;
  try { source = await readFile(path, "utf-8"); }
  catch (err) { return { ok: false, error: { kind: "read_failed", path, cause: String(err) } }; }

  let parsed: matter.GrayMatterFile<string>;
  try { parsed = matter(source); }
  catch (err) { return { ok: false, error: { kind: "yaml_parse_failed", path, cause: String(err) } }; }

  const validated = FrontmatterSchema.safeParse(parsed.data);
  if (!validated.success) {
    const issues = validated.error.issues.map(i => `${i.path.join(".")}: ${i.message}`);
    return { ok: false, error: { kind: "invalid_frontmatter", path, issues } };
  }
  return { ok: true, post: { path, frontmatter: validated.data, body: parsed.content.trim() } };
}

Notice this loader never throws. Every downstream site is a switch on result.ok followed by narrowing on result.error.kind. Throwing hides errors behind exception plumbing that no framework agrees on. Returning a tagged union puts the error path in the type system, and TypeScript's exhaustiveness checker (with strict on) flags the moment you add a new LoaderError variant and forget to handle it downstream.

Compare that with the naive version:

// naive loader, do not ship this
async function parseMdxNaive(path: string): Promise<Post> {
  const raw = await readFile(path, "utf-8");
  const parsed = matter(raw);
  return { path, frontmatter: parsed.data as Frontmatter, body: parsed.content };
}

The as Frontmatter cast is a runtime lie. parsed.data is Record<string, unknown>. If someone renames date to published_at, the naive loader hands you an object with no date field and the type of frontmatter.date is still string. You get undefined at runtime and an assertion error miles from the actual bug. The safeParse loader catches it at the content/hello-world.mdx boundary, with the file path and the field name right there in the error message.

Step 4: Wire a build-time validator CLI (commit cec7692)

The loader is per-file. The CLI is per-repo. It walks content/, calls parseMdx on every .mdx file, and exits non-zero if any file fails.

export async function validateContent(root = "content"): Promise<ValidationSummary> {
  const files = await listMdxFiles(root);
  const failed: LoaderError[] = [];
  for (const file of files) {
    const result = await parseMdx(file);
    if (!result.ok) failed.push(result.error);
  }
  return { total: files.length, failed };
}

listMdxFiles is a small recursive readdir that returns .mdx paths in sorted order. Sorting matters because otherwise the CI diff between two runs of the same content set flaps randomly based on filesystem order, and reviewing diffs of validator output becomes painful fast.

On success the CLI prints:

OK: 2 file(s) validated

On failure it prints one block per bad file, with the path, the error kind, and the Zod issue paths:

FAIL: 1/12 file(s) invalid

  content/broken-post.mdx
    invalid_frontmatter:
      - date: date must be YYYY-MM-DD
      - keywords: at least one keyword required

Drop this as a single line in your CI job (bun run validate) and the content team gets a live error message with the file, the field, and the fix, without a TypeScript compiler in the loop. Local run cost is negligible: on an M-series MacBook with twelve posts the whole walk finishes in about 30ms, and a project with a thousand posts still lands in single-digit seconds.

Here's how the three common approaches to catching bad frontmatter compare across the lifecycle of a content pipeline:

ApproachFailure caught atAuthor feedback loopCost per post added
Manual PR reviewReviewer's eyesMinutes to hoursO(N), reviewer time scales linearly
Runtime cast + asProduction renderNever (silent)O(1) writing, O(N) debugging
Zod schema + CI validatorCI buildSeconds locally, seconds in CIO(1) once the schema exists

The middle row is the default state of most content pipelines. The top row is what teams do when they realise the middle row isn't working. The bottom row is where you want to end up, and it costs one afternoon of scaffolding to get there.

Step 5: Discriminated union for post kinds (commit ce13c4e)

Once the pipeline has more than one post shape (guide, release notes, tutorial, changelog), the single FrontmatterSchema needs to grow. Zod's discriminatedUnion is the right tool. Every variant tags itself with a literal kind, and Zod narrows both the runtime object and the TypeScript type off that tag.

const BaseFields = { title: /* ... */, description: /* ... */, date: /* ... */, keywords: /* ... */, author: /* ... */, draft: /* ... */ } as const;

const GuideFrontmatter = z.object({
  kind: z.literal("guide"),
  ...BaseFields,
  reading_time_min: z.number().int().positive().max(120),
}).strict();

const ReleaseNotesFrontmatter = z.object({
  kind: z.literal("release-notes"),
  ...BaseFields,
  version: z.string().regex(/^\d+\.\d+\.\d+$/, "version must be semver X.Y.Z"),
  breaking: z.array(z.string().min(5)).default([]),
}).strict();

export const FrontmatterSchema = z.discriminatedUnion("kind", [
  GuideFrontmatter,
  ReleaseNotesFrontmatter,
]);

Call-site narrowing works out of the box:

const result = await parseMdx("content/release-2.0.mdx");
if (!result.ok) throw result.error;
if (result.post.frontmatter.kind === "release-notes") {
  console.log(result.post.frontmatter.version); // typed as string, no cast
}

Two subtle wins ship with this pattern.

First, Zod's error messages get better. When a guide post is missing reading_time_min, the failure says reading_time_min: Required, not the opaque Union member 0 did not match. discriminatedUnion parses down a single branch based on the tag, so error paths point at the missing field on the intended variant.

Second, adding a new post kind is one z.object and one line in the union array. No touching the loader, no touching the CLI, no touching existing content files. The renderer picks up the new kind by switching on frontmatter.kind, which the exhaustiveness checker flags at compile time if you miss a branch.

Compare that with an object-of-optional-fields approach where every field is title?: string, version?: string, reading_time_min?: number and you rely on convention to know which fields belong together. That shape gives you no compile-time protection when someone puts a version field on a guide post, and every renderer has to sprinkle if (frontmatter.version) { /* ... */ } checks that pass linting but say nothing about intent. Discriminated unions turn intent into a compile-time invariant, which is exactly the property you want as the content set grows past a dozen posts.

Repository

Full source at https://github.com/vytharion/mdx-frontmatter-zod-contract.

  • Step 1 → 97205c1 : Bun + TypeScript scaffold with Zod and gray-matter installed
  • Step 2 → 2ceda2f : Zod schema for MDX frontmatter with defaults and strict mode
  • Step 3 → 6cfd6e4 : parseMdx loader with gray-matter and typed Result error path
  • Step 4 → cec7692 : CI validator CLI that walks content/ and exits non-zero on failure
  • Step 5 → ce13c4e : discriminated union across guide and release-notes post kinds

Every step passes bun test on its own. Clone the repo, run bun install, git checkout <sha>, run bun test, read the tests, then move to the next commit. Modify the schema to add a tutorial kind, watch the exhaustiveness checker highlight every render call site that now needs to handle it.

Where to go next

Wire the validator into GitHub Actions. Add a content-check job that runs bun run validate on every PR. Zod's error messages surface in the check output verbatim, so content authors can see the fix without leaving GitHub.

Extend the schema with cross-field refinements. A release-notes post whose date predates its version is probably a mistake, and a .refine at the object level catches it. A guide whose reading_time_min is ninety but whose body is three hundred words is another candidate for a .refine.

Publish the schema as a small library other repos can import. If you have three or four content projects, a shared package removes the copy-paste drift and turns "the frontmatter contract" from a document into a semver-versioned dependency that ships breaking changes with a version bump instead of a Slack thread.

Useful external references:

The pattern generalises. Any file format with a structured header block (MDX, Jupyter notebook cells, markdown-plus-YAML config) benefits from a schema-first parse. The parser at the front changes; the schema-and-typed-result discipline behind it stays the same. Clone the repo, break the schema on purpose, and watch how much noise the CI validator strips out of your review loop.