|

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:

PartWhat we covered
1Why Astro over Hugo — components, TypeScript, zero-JS output
2Contentful setup — content models, SDK, migration script
3Astro + Contentful — dynamic routes, Rich Text, i18n
4Build optimization — images, SEO, sitemap, RSS
5Docker + Nginx — multi-stage build, caching, CI/CD
6Zod 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.