Content Collections
Build a scalable, type-safe blog using Next.js, MDX, and Zod-powered content collections.
4 dk okuma
Bu sayfada
Preface
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.
Packages
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
Content Structure
Each blog post lives inside a content collection directory.
Folder structure
content/blog/*.mdx— Blog postslib/content.ts— Content utilities + schemaslib/format-date.ts— Date formatting helperapp/blog/[slug]/page.tsx— Blog post pageapp/page.tsx— Blog index
- content
- blog
- intro.mdx
- nextjs.mdx
- mdx.mdx
- blog
- lib
- content.ts
- format-date.ts
- app
- page.tsx
- blog
- [slug]
Writing Content (MDX)
Each post is a .mdx file with front matter.
---title: Understanding Content Collectionsdate: "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.
Collecting All Posts
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.
Conclusion
Content collections become truly powerful when paired with schema validation.
With Next.js + MDX + Zod, you get:
-
Static performance
-
Type safety
-
Full control
-
Zero external dependencies
-
This setup scales from a personal blog to full documentation systems.