Building a Type-Safe Content Layer from Scratch

One of the first decisions when building any content-heavy site is how to manage the content itself. CMSes like Contentful or Sanity offer polished editing experiences but add complexity, cost, and an external dependency. For my portfolio, I wanted the content to live in the repository — version-controlled, portable, and instantly editable in my IDE.

Why Not Just Parse Markdown?

You could read markdown files with fs.readFileSync, parse them with a library, and call it a day. But this approach silently breaks in frustrating ways. Forget a required frontmatter field? You get undefined at runtime. Typo in a date string? The page renders with “Invalid Date” instead of failing the build. Rename a field in one file but not another? Good luck debugging that in production.

The problem isn’t parsing — it’s validation. And validation without types is just runtime error handling in disguise.

Schema-Driven Content

Astro’s content collections solve this elegantly. You define a Zod schema for each collection, and every markdown file is validated against it at build time. A missing field, a wrong type, an invalid enum value — all caught before your site ever deploys.

Here’s the mental model: your content directory is a database, your frontmatter fields are columns, and your Zod schema is the table definition. The build process is your migration check. If the data doesn’t match the schema, the build fails with a clear error message pointing to the exact file and field.

This sounds simple, but the downstream effects are significant. Your templates get full TypeScript inference. You can safely destructure data.title, data.pubDate, data.tags without null checks because the schema guarantees they exist and have the correct type. Refactoring becomes safe — rename a field in the schema and TypeScript will flag every template that references the old name.

Beyond Simple Fields

The real power shows up with complex schemas. For my projects collection, each entry has a typed array of “facts” that can be either text or tag arrays, plus a list of action buttons with optional icon identifiers. Expressing this in a CMS would require custom field types or JSON blobs. With Zod, it’s just composition:

const factSchema = z.object({
  label: z.string(),
  value: z.union([z.string(), z.array(z.string())]),
  type: z.enum(["text", "tags"]).default("text"),
});

Every project file is validated against this nested schema. The TypeScript types flow through to the template, so I get autocomplete on fact.type and the union discriminator narrows fact.value to the correct type.

The Trade-off

You lose the nice editing UI that a CMS provides. Non-technical collaborators can’t easily update content without touching code. For a personal portfolio, that’s fine. For a team blog or a client project, you might want to layer a git-based CMS like Tina or Decap on top of the same file structure.

But for solo developers, this approach hits a sweet spot: maximum type safety, zero infrastructure, and your content lives right next to your code where it belongs.