Building a blog with Next.js and MDX has become increasingly popular among developers who want to combine the power of React components with the simplicity of Markdown. In this tutorial, I'll walk you through creating a fully-functional blog from scratch.
Here are the essential packages we'll be working with:
Next.js - The React framework for production
Content Collections - For managing and validating MDX content
Shiki - Syntax highlighting for code blocks
Rehype/Remark plugins - To enhance markdown processing
MDX allows you to write JSX directly in your markdown files, giving you the flexibility to:
Embed interactive React components within blog posts
Create reusable content blocks
Maintain type-safe frontmatter with validation
Generate static pages at build time for optimal performance
Let's start by creating a new Next.js project with TypeScript support:
pnpm create next-app@latest my-blog --typescript --tailwind --app
cd my-blog
We'll need a few additional packages to handle MDX content:
pnpm add @content-collections/core @content-collections/mdx
pnpm add -D @content-collections/next
Here's the recommended folder structure for your blog:
app/ - Next.js App Router pages
content/blog/ - Your MDX blog posts
components/mdx/ - Custom MDX components
content-collections.ts - Content schema definition
Create a content-collections.ts file in your project root to define the structure of your blog posts:
import { defineCollection, defineConfig } from '@content-collections/core'
import { compileMDX } from '@content-collections/mdx'
const posts = defineCollection ({
name: 'posts' ,
directory: 'content/blog' ,
include: '*.mdx' ,
schema : ( z ) => ({
title: z. string (),
date: z. string (),
summary: z. string (),
modifiedTime: z. string (). optional (),
}),
transform : async ( document , context ) => {
const mdx = await compileMDX (context, document)
return {
... document,
slug: document._meta.path,
content: mdx,
}
},
})
export default defineConfig ({
collections: [posts] ,
})
This configuration provides:
Type safety for your frontmatter
Automatic validation of blog post metadata
MDX compilation at build time
Slug generation from file paths
Now let's create a sample blog post. Create a new file at content/blog/hello-world.mdx:
content/blog/hello-world.mdx ---
title : Hello World - My First Blog Post
date : '2024-01-15T00:00:00Z'
summary : Welcome to my new blog built with Next.js and MDX!
---
## Welcome!
This is my first blog post using ** Next.js ** and ** MDX ** .
### What I Can Do
With MDX, I can:
1 . Write regular markdown
2 . Use React components
3 . Create interactive content
Pretty cool, right?
Create your blog listing page at app/blog/page.tsx:
import { allPosts } from 'content-collections'
import Link from 'next/link'
export default function BlogPage () {
// Sort posts by date (newest first)
const sortedPosts = allPosts. sort (( a , b ) =>
new Date (b.date). getTime () - new Date (a.date). getTime ()
)
return (
< div className = 'mx-auto max-w-4xl px-4 py-12' >
< h1 className = 'mb-8 text-4xl font-bold' > Blog Posts </ h1 >
< div className = 'space-y-6' >
{ sortedPosts . map (( post ) => (
< article
key = {post.slug}
className = 'rounded-lg border p-6 transition-shadow hover:shadow-lg'
>
< Link href = { `/blog/${ post . slug }` } >
< h2 className = 'mb-2 text-2xl font-semibold' >
{ post . title }
</ h2 >
< time className = 'text-sm text-gray-600' >
{ new Date ( post . date ). toLocaleDateString (' en - US ', {
year : 'numeric' ,
month : 'long' ,
day : 'numeric'
})}
</ time >
< p className = 'mt-3 text-gray-700' > {post.summary} </ p >
</ Link >
</ article >
))}
</ div >
</ div >
)
}
Now create the dynamic route for individual posts at app/blog/[slug]/page.tsx:
import { allPosts } from 'content-collections'
import { notFound } from 'next/navigation'
import { MDXContent } from '@content-collections/mdx/react'
export function generateStaticParams () {
return allPosts. map (( post ) => ({
slug: post.slug,
}))
}
export default function BlogPostPage ({
params
} : {
params : { slug : string }
}) {
const post = allPosts. find (( p ) => p.slug === params.slug)
if ( ! post) {
notFound ()
}
return (
< article className = 'mx-auto max-w-3xl px-4 py-12' >
< header className = 'mb-8' >
< h1 className = 'mb-4 text-4xl font-bold' > {post.title} </ h1 >
< time className = 'text-gray-600' >
{ new Date ( post . date ). toLocaleDateString (' en - US ', {
year : 'numeric' ,
month : 'long' ,
day : 'numeric'
})}
</ time >
</ header >
< div className = 'prose prose-lg max-w-none' >
< MDXContent code = {post.content} />
</ div >
</ article >
)
}
Update your next.config.js to enable Content Collections:
import { withContentCollections } from '@content-collections/next'
/** @type {import('next').NextConfig} */
const nextConfig = {
// Your Next.js config
}
export default withContentCollections ( nextConfig )
You now have a fully functional blog with:
✅ Type-safe content management
✅ MDX support for rich content
✅ Automatic routing
✅ Static generation for optimal performance
Want to take your blog further? Consider adding:
Code syntax highlighting with Shiki or Prism
Reading time estimates
Table of contents generation
Related posts suggestions
RSS feed for subscribers
Search functionality
Tags and categories for organization