Type-Safe Content with Contentful and Zod (Part 6/7)
Last one in the series! We’ve got the site built, content flowing from Contentful, and deployments automated. But there’s a gap in the safety net.
Right now, if someone publishes a blog post in Contentful with a missing excerpt or a malformed date, the build might silently produce broken pages. Wouldn’t it be nice if the build just… told you?
That’s where Zod comes in.
The problem
Contentful gives you type generation through tools like cf-content-types-generator. Those types are great, but they only describe the shape of the data — they don’t validate it at runtime. TypeScript types disappear after compilation.
So you can have a perfectly typed IBlogPostFields interface, and still get null where you expected a string. CMS content is user input. User input lies.
What Zod adds
Zod gives you schemas that validate data at runtime. If the data doesn’t match, you get a clear error instead of a silent failure:
import { z } from 'zod'
const BlogPostSchema = z.object({
title: z.string().min(1),
slug: z.string().regex(/^[a-z0-9-]+$/),
date: z.string().datetime(),
excerpt: z.string().max(300),
tags: z.array(z.string()).min(1),
tagsColor: z.string().regex(/^#[0-9a-fA-F]{6}$/),
draft: z.boolean(),
featuredImage: z.object({
fields: z.object({
title: z.string(),
file: z.object({
url: z.string(),
contentType: z.string()
})
})
}).optional()
})
type BlogPost = z.infer<typeof BlogPostSchema>
Now your types and your validation are the same thing. Define once, use everywhere.
Validating Contentful responses
Here’s how I’d update the data-fetching layer to validate everything coming from Contentful:
// src/lib/contentful.ts
import contentful from 'contentful'
import { z } from 'zod'
const client = contentful.createClient({
space: import.meta.env.CONTENTFUL_SPACE_ID,
accessToken: import.meta.env.CONTENTFUL_DELIVERY_TOKEN,
host: 'cdn.contentful.com'
})
// Schemas
const BlogPostSchema = z.object({
title: z.string().min(1),
slug: z.string().regex(/^[a-z0-9-]+$/),
date: z.string(),
excerpt: z.string(),
body: z.any(), // Rich Text is complex, validate structure separately
tags: z.array(z.string()).min(1),
tagsColor: z.string().regex(/^#[0-9a-fA-F]{6}$/),
draft: z.boolean().default(false),
featuredImage: z.any().optional()
})
const ProjectSchema = z.object({
title: z.string().min(1),
slug: z.string(),
description: z.string(),
technologies: z.array(z.string()),
url: z.string().url().optional().or(z.literal('')),
image: z.any().optional(),
order: z.number()
})
const PageSchema = z.object({
title: z.string().min(1),
slug: z.string(),
body: z.any(),
seoDescription: z.string().max(160).optional()
})
// Types inferred from schemas
type BlogPost = z.infer<typeof BlogPostSchema>
type Project = z.infer<typeof ProjectSchema>
type Page = z.infer<typeof PageSchema>
export { type BlogPost, type Project, type Page }
Safe parsing
Now here’s where it gets practical. Instead of blindly trusting Contentful’s response, parse it:
function validateEntry<T>(schema: z.ZodSchema<T>, entry: any, label: string): T {
const result = schema.safeParse(entry.fields)
if (!result.success) {
console.error(`Validation failed for ${label}:`)
result.error.issues.forEach(issue => {
console.error(` → ${issue.path.join('.')}: ${issue.message}`)
})
throw new Error(`Invalid content: ${label}`)
}
return result.data
}
Now your fetchers become:
export async function getBlogPosts() {
const entries = await client.getEntries({
content_type: 'blogPost',
order: ['-fields.date'],
'fields.draft': false
})
return entries.items.map(entry =>
validateEntry(BlogPostSchema, entry, `Blog Post: ${entry.fields.title}`)
)
}
export async function getBlogPost(slug: string) {
const entries = await client.getEntries({
content_type: 'blogPost',
'fields.slug': slug,
include: 10,
limit: 1
})
const entry = entries.items[0]
if (!entry) return null
return validateEntry(BlogPostSchema, entry, `Blog Post: ${slug}`)
}
export async function getProjects() {
const entries = await client.getEntries({
content_type: 'project',
order: ['fields.order']
})
return entries.items.map(entry =>
validateEntry(ProjectSchema, entry, `Project: ${entry.fields.title}`)
)
}
export async function getPage(slug: string) {
const entries = await client.getEntries({
content_type: 'page',
'fields.slug': slug,
limit: 1
})
const entry = entries.items[0]
if (!entry) return null
return validateEntry(PageSchema, entry, `Page: ${slug}`)
}
Publish a blog post without tags? The build fails with a clear message:
Validation failed for Blog Post: My Draft Post:
→ tags: Array must contain at least 1 element(s)
Way better than a cryptic Cannot read property 'map' of undefined in production.
Graceful handling vs. hard fails
For a personal site, I’d go with hard fails. If content is invalid, the build should stop. Better to catch it than to ship broken pages.
But if you wanted to be gentler — say, skip invalid entries instead of crashing — you could do this:
export async function getBlogPosts() {
const entries = await client.getEntries({
content_type: 'blogPost',
order: ['-fields.date'],
'fields.draft': false
})
const valid = []
for (const entry of entries.items) {
const result = BlogPostSchema.safeParse(entry.fields)
if (result.success) {
valid.push(result.data)
} else {
console.warn(`Skipping "${entry.fields.title}": ${result.error.message}`)
}
}
return valid
}
Invalid posts get logged and skipped. The build continues. Depends on how strict you want to be.
Validating Rich Text structure
Rich Text is the tricky one. The full AST is deeply nested and validating every node type would be verbose. I’d keep it practical:
const RichTextSchema = z.object({
nodeType: z.literal('document'),
content: z.array(z.object({
nodeType: z.string(),
content: z.any().optional(),
data: z.any().optional()
})).min(1)
})
const BlogPostSchema = z.object({
title: z.string().min(1),
slug: z.string().regex(/^[a-z0-9-]+$/),
date: z.string(),
excerpt: z.string(),
body: RichTextSchema, // basic structure check
tags: z.array(z.string()).min(1),
tagsColor: z.string().regex(/^#[0-9a-fA-F]{6}$/),
draft: z.boolean().default(false),
featuredImage: z.any().optional()
})
This confirms the body is a Rich Text document with at least one content node. It won’t catch every possible issue, but it’ll catch the most common one: empty body.
Reusing schemas across the project
Keep schemas in one place:
// src/schemas/contentful.ts
import { z } from 'zod'
export const BlogPostSchema = z.object({ /* ... */ })
export const ProjectSchema = z.object({ /* ... */ })
export const PageSchema = z.object({ /* ... */ })
export type BlogPost = z.infer<typeof BlogPostSchema>
export type Project = z.infer<typeof ProjectSchema>
export type Page = z.infer<typeof PageSchema>
Import them in your data layer, your components, wherever. One source of truth for both types and validation.
The full picture
Here’s what happened across the series:
| Part | What we covered |
|---|---|
| 1 | Why Astro over Hugo — components, TypeScript, zero-JS output |
| 2 | Contentful setup — content models, SDK, migration script |
| 3 | Astro + Contentful — dynamic routes, Rich Text, i18n |
| 4 | Build optimization — images, SEO, sitemap, RSS |
| 5 | Docker + Nginx — multi-stage build, caching, CI/CD |
| 6 | Zod validation — type-safe content at build time |
Almost done. One more to go — Part 7 covers securing secrets in Docker builds. Because passing API tokens as build args works, but it’s not great from a security perspective. Let’s fix that.
Keep pushing forward and savor every step of your coding journey.
