Back to all articles
TypeScript6 min read

Type-Safe Content Pipelines with Zod & Astro

How to construct schema-validated Markdown and JSON pipelines that catch data anomalies, invalid links, and frontmatter drift at compile-time.

Audio NarrationNeural AI Voice
Listen to this article (6 min read)
0%

Static site generation is only as reliable as the data feeding into it. In traditional Markdown and CMS workflows, data models suffer from continuous “schema drift”:

  • An author formats a date as 12/05/2024 instead of ISO 2024-05-12.
  • A tag is misspelled as ['typesript'] instead of ['typescript'].
  • An optional featuredImage URL points to a dead asset path.
  • A required author field is omitted entirely.

In unstructured pipelines, these subtle mistakes slip silently into production, causing runtime JavaScript crashes, distorted CSS grids, or broken metadata cards.

By combining Astro Content Collections with Zod, we turn content into a compiled, type-safe data pipeline.


1. Defining Strict Zod Schemas

In Astro 5+, collection loaders allow you to validate both local Markdown files and remote API payloads using declarative Zod schemas in src/content.config.ts:

// src/content.config.ts
import { defineCollection } from 'astro:content';
import { z } from 'astro/zod';
import { glob } from 'astro/loaders';

export const collections = {
  projects: defineCollection({
    loader: glob({ pattern: '**/[^_]*.md', base: './src/content/projects' }),
    schema: z.object({
      title: z.string().min(3),
      description: z.string().max(160),
      summary: z.string().optional(),
      category: z.enum(['web-app', 'open-source', 'cli-tool', 'systems', 'design-engineering']),
      tags: z.array(z.string()).min(1),
      featured: z.boolean().default(false),
      liveUrl: z.string().url().optional(),
      githubUrl: z.string().url().optional(),
      year: z.number().int().min(2020).max(2030),
      role: z.string().default('Lead Engineer'),
      publishDate: z.string().regex(/^\d{4}-\d{2}-\d{2}$/, 'Must be YYYY-MM-DD format'),
    }),
  }),
};

2. Compile-Time Failure with Precise Line Coordinates

When an author commits a markdown file with an invalid category enum (category: 'mobile-app'), running pnpm check or pnpm build fails immediately:

[content] Failed to validate content file: src/content/projects/new-tool.md
[ZodError]: [
  {
    "code": "invalid_enum_value",
    "options": ["web-app", "open-source", "cli-tool", "systems", "design-engineering"],
    "path": ["category"],
    "message": "Invalid enum value. Expected 'web-app' | 'open-source' | 'cli-tool' | 'systems' | 'design-engineering', received 'mobile-app'"
  }
]

The error stops the CI/CD deployment pipeline before bad data ever reaches Cloudflare Pages or Vercel.


3. End-to-End Type Safety in UI Templates

Because Astro compiles these schemas into internal TypeScript declarations, your Astro components enjoy full autocomplete and type inference without manual interface casting:

---
// src/components/portfolio/ProjectCard.astro
import type { CollectionEntry } from 'astro:content';

export interface Props {
  project: CollectionEntry<'projects'>;
  featured?: boolean;
}

const { project, featured = false } = Astro.props;
const { data, id } = project;

// TypeScript knows 'data.category' is strictly the 5-item union
const isCli = data.category === 'cli-tool';
---

<article
  class={`rounded-xl border border-base-300 bg-base-200 p-6 transition-all duration-200 hover:-translate-y-0.5 ${featured ? 'border-accent/40' : ''}`}
>
  <span class="text-accent font-mono text-xs uppercase">{data.category}</span>
  <h3 class="text-xl font-bold">{data.title}</h3>
  <p class="text-base-content/75 text-sm">{data.description}</p>

  <div class="mt-4 flex gap-2">
    {
      data.tags.map((tag) => (
        <span class="bg-base-300 rounded px-2 py-0.5 font-mono text-xs">#{tag}</span>
      ))
    }
  </div>
</article>

4. Key Production Benefits

  1. Zero Runtime Null Checks: You never have to write defensive checks like if (project.data.tags && project.data.tags.length > 0) because Zod guarantees the structure at build time.
  2. Deterministic SEO Metadata: Every page automatically receives valid dates, character-capped titles, and validated URLs for Schema.org JSON-LD generation.
  3. Effortless Refactoring: If you rename a field in src/content.config.ts, TypeScript immediately highlights every component that needs updating.