Overview
This isn’t going to be a deep introspective on all of the magic that Zod is. For this discussion, Zod is simply this nifty widget used as part of Astro’s content collection to define a typesafe schema for the frontmatter of a blog post. This has a ton of benefits … most notable (for me) is the amazing errors displayed when the frontmatter is somehow incorrect.
From the docs, Zod is described as:
… a TypeScript-first schema declaration and validation library. I’m using the term “schema” to broadly refer to any data type, from a simple string to a complex nested object …
If you find yourself cornered by a well-armed attacker demanding a typesafe schema, Zod would be essential to saving your life. However, if they just want your wallet or watch, it probably won’t do much besides confuse them.

Why do I care?
Under normal circumstances, I absolutely wouldn’t care since setting up the schema for content collections is a pretty straightforward affair. Just think through the fields you would use to characterize your blog posts, copy and paste the boilerplate, and season to taste.
For this grossly unused blog, this looked like this:
import { z, defineCollection } from "astro:content";
const blogCollection = defineCollection({
schema: z.object({
isDraft: z.boolean(),
title: z.string(),
image: z
.object({
src: z.string(),
alt: z.string(),
})
.optional(),
tags: z.array(z.string()).default(["lost thoughts"]),
publishDate: z.string().transform((str) => new Date(str)),
description: z.string().optional(),
comments: z.boolean().default(false),
}),
});
export const collections = {
blog: blogCollection,
};
The only thing of interest here is the image
object which has parameters for an image src
and alt
designation. In this case, it’s optional, reflecting my anticipated laziness in sourcing images for blog content. The thinking was “specify an image. If not, just get a random one”.
Houston is an Idiot
Well, this approach worked without issue. The Astro component code wasn’t particularly sexy, but it was serviceable. Unfortunately, the entire concept was poorly thought out. For example, when the blog cards were rendered, they would dynamically fetch an image if one didn’t exist (as planned) … every time the page loaded (not as planned).

While I’m sure there is a use case for that behavior, it was just irritating and borderline seizure-inducing in practice.
Not being a software developer by nature, it took a bit to muddle through a bunch of equally bad ideas on fixing it. Ultimately, I found a solution by modifying the frontmatter of each blog directly. Again, the logic seemed sound: have an image, use it - if not, get a random one. Only this time, do it as part of the build avoiding the epileptic reader risk.
All was right with the world. Images worked as expected.

Correction: it worked as expected using the dev server, which does magical server things in the background. For a static site (like this), it did … nothing. It turns out there’s an evil purple note in the docs describing this very phenomenon.
Kneel before Zod
After a bit of back and forth, desperate pleas for help in the Astro discord, and rereading the docs a few dozen times, something from that note stuck in my head.
Remember, all data in Astro components is fetched when a component is rendered.
Rendered … that seems somewhat important. For this case, I’m using dynamically generated routes for a static site lifted almost verbatim:
---
import { getCollection } from "astro:content";
import PageLayout from "@layouts/pageLayout.astro";
export async function getStaticPaths() {
const blogEntries = await getCollection("blog");
return blogEntries.map((entry) => ({
params: { slug: entry.slug },
props: { entry },
}));
}
const { entry } = Astro.props;
const { Content } = await entry.render();
---
<PageLayout post={entry}>
<Content />
</PageLayout>
At this point, the benevolent nerdminders on Discord concluded that the blog post frontmatter had to be modified prior to calling getCollection()
for it to be available to all consumers of the collection. Any frontmatter changes subsequent to that (i.e. within other Astro components) would not affect the staticly rendered content. Seems plausible to me … but how do we fix it?
Fundamentally, we’re trying to set a default entry for the image
object in the frontmatter, which is described by the Zod schema (see above). In this case, it’s a bit sticky since we’re not providing a simple default value but an object whose parameters are defined by resolving a fetch()
to the Unsplash API.
Thankfully, the big brains at Zod have already thought this through and provided a transform()
method which allows you to pass an async closure to the schema (note: 70% sure this is the correct way to describe this). In this case, it just checks for the existence of the image object in the frontmatter: if it’s there, use it - if not, go get it and return the default image object.
import { z, defineCollection } from "astro:content";
const blogCollection = defineCollection({
schema: z.object({
isDraft: z.boolean(),
title: z.string(),
image: z
.object({
src: z.string(),
alt: z.string(),
})
.optional()
// Kneel Before Zod!!!!! 🦾
.transform(async (obj) => {
if (!obj) {
const response = await fetch("https://source.unsplash.com/random/?technology&dpr=2&crop=faces,center");
return {
src: response.url,
alt: "Random image from Unsplash",
};
} else {
return obj;
}
}),
tags: z.array(z.string()).default(["lost thoughts"]),
publishDate: z.string().transform((str) => new Date(str)),
description: z.string().optional(),
comments: z.boolean().default(false),
}),
});
export const collections = {
blog: blogCollection,
};
Heartfelt thanks to the fine folks on the Astro discord! Hopefully, this will help someone or me when I forget how I did it.