Перейти к содержанию
Hamza Kargin

Content Collections

Build a scalable, type-safe blog using Next.js, MDX, and Zod-powered content collections.

4 мин чтения

На этой странице

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 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
  • content
    • blog
      • intro.mdx
      • nextjs.mdx
      • mdx.mdx
  • 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 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

Typescript
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

Typescript
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

TypeScript
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

TypeScript
export const formatDate = (date: string) =>
new Date(date).toLocaleDateString("en", {
year: "numeric",
month: "long",
day: "numeric",
});

Blog Index Page

TypeScript
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

TypeScript
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.

Страницы

ГлавнаяПроектыОбо мнеБлогЖурнал релизовКонтакты

Статьи

Why I Moved My Portfolio From Next.js to AstroFour Languages in Astro Without an i18n LibraryWhat I Learned Shipping My First Production AppManaging Server StateState Management With ZustandContent CollectionsAggregation Pipeline

Тема

Язык

EnglishРусскийTürkçeDeutsch
Перейти↵