Modern blogs are no longer just pages — they are content collections .
Instead of hardcoding posts or relying entirely on a CMS, we can treat our blog as a structured content layer powered by MDX + filesystem + schema validation .
By adding Zod , we get:
Type-safe front matter
Runtime validation
Clear content contracts
Early error detection during build
This article walks through building a type-safe content collection blog with Next.js.
The main packages we’ll use:
next — React framework
mdx — Markdown with JSX
gray-matter — front matter parser
zod — schema validation
fs — filesystem access
path — path utilities
Each blog post lives inside a content collection directory.
content/blog/*.mdx — Blog posts
lib/content.ts — Content utilities + schemas
lib/format-date.ts — Date formatting helper
app/blog/[slug]/page.tsx — Blog post page
app/page.tsx — Blog index
Each post is a .mdx file with front matter.
---
title : Understanding Content Collections
date : "2025-01-10T00:00:00Z"
summary : Why content collections scale better than traditional blogs.
tags : [ "nextjs" , "content" , "mdx" ]
published : true
---
## What is a Content Collection?
A content collection is a structured group of content files that share the same schema.
This metadata will be **validated at build time** using Zod.
### Defining a Content Schema (Zod)
This is the key difference from a basic MDX setup.
```ts title='Typescript'
import { z } from "zod" ;
export const blogSchema = z. object ({
title: z. string (),
date: z. string (). datetime (),
summary: z. string (),
tags: z. array (z. string ()). default ([]),
published: z. boolean (). default ( true ),
});
export type BlogFrontMatter = z . infer < typeof blogSchema>;
```
Now every blog post must conform to this schema.
Reading Content from the Filesystem
import path from "path" ;
import fs from "fs" ;
export const CONTENT_PATH = path. join (process. cwd (), "content" , "blog" );
export const getAllSlugs = () =>
fs. readdirSync ( CONTENT_PATH ). filter (( file ) => file. endsWith ( ".mdx" ));
export const formatSlug = ( slug : string ) => slug. replace ( / \. mdx $ / , "" );
Reading and Validating a Single Post
import matter from "gray-matter" ;
export const getPostBySlug = ( slug : string ) => {
const filePath = path. join ( CONTENT_PATH , `${ slug }.mdx` );
const source = fs. readFileSync (filePath, "utf-8" );
const { content , data } = matter (source);
const parsed = blogSchema. safeParse (data);
if ( ! parsed.success) {
throw new Error (
`Invalid front matter in ${ slug }.mdx \n ${ parsed . error . message }` ,
);
}
return {
content,
frontMatter: {
... parsed.data,
slug,
},
};
};
At this point, invalid content will fail the build — exactly what we want.
export const getAllPosts = () => {
return getAllSlugs ()
. map (( slug ) => {
const source = fs. readFileSync (path. join ( CONTENT_PATH , slug), "utf-8" );
const { data } = matter (source);
const parsed = blogSchema. safeParse (data);
if ( ! parsed.success || ! parsed.data.published) {
return null ;
}
return {
... parsed.data,
slug: formatSlug (slug),
date: new Date (parsed.data.date). toISOString (),
};
})
. filter (Boolean)
. sort (( a , b ) => (a ! .date > b ! .date ? - 1 : 1 ));
};
Now your blog automatically ignores:
Draft posts
Invalid front matter
Broken metadata
Formatting Dates
export const formatDate = ( date : string ) =>
new Date (date). toLocaleDateString ( "en" , {
year: "numeric" ,
month: "long" ,
day: "numeric" ,
});
Blog Index Page
import Link from 'next/link'
import { getAllPosts } from '@/lib/content'
import { formatDate } from '@/lib/format-date'
export default function Home () {
const posts = getAllPosts ()
return (
< section >
< h1 className = 'text-5xl font-bold mb-10' > Blog </ h1 >
< ul className = 'space-y-6' >
{ posts . map (( post ) => (
< li key = {post.slug} >
< Link href = { `/blog/${ post . slug }` } >
< h2 className = 'text-2xl font-semibold' >
{ post . title }
</ h2 >
< time className = 'text-sm opacity-70' >
{ formatDate ( post . date )}
</ time >
< p className = 'mt-2' > {post.summary} </ p >
</ Link >
</ li >
))}
</ ul >
</ section >
)
}
Blog Post Page
import { getPostBySlug, getAllSlugs } from "@/lib/content" ;
import { formatDate } from "@/lib/format-date" ;
import { MDXRemote } from "next-mdx-remote/rsc" ;
export default function BlogPost ({ params }) {
const post = getPostBySlug (params.slug);
return (
< article className = "prose max-w-none" >
< h1 > { post.frontMatter.title } </ h1 >
< time > { formatDate (post.frontMatter.date) } </ time >
< MDXRemote source ={ post.content } />
</ article >
);
}
export const generateStaticParams = () =>
getAllSlugs (). map (( slug ) => ({
slug: slug. replace ( ".mdx" , "" ),
}));
Why Zod Changes Everything
-- Content errors fail fast
-- Metadata is self-documented
-- Refactors are safe
-- IDE autocomplete for content fields
-- No more “undefined front matter” bugs
This is essentially a headless CMS inside your repo.
Content collections become truly powerful when paired with schema validation.
With Next.js + MDX + Zod, you get: