How to Build a Modern Blog Website with Next.js and MDX (Without Losing Your Sanity)
So you want to build a blog with Next.js, huh? Let me guess - you thought it would be as simple as creating a few components, maybe throwing in some dynamic routes, and calling it a day. Well, congratulations on being adorably naive! Welcome to the wonderful world of content management, where "simple" is just a four-letter word that developers use to lie to themselves.
The "Easy" Way That Isn't Actually Easy
The Initial Delusion
Initially, I had the same bright idea as everyone else:
app/
├── blog/
│ ├── [id]/
│ │ └── page.tsx
│ └── page.tsx
"Just create a dynamic route in Next.js!" I thought. "Map through some blog data!" I said. "It'll be so clean!" I proclaimed to my rubber duck.
But then reality hit harder than a TypeScript error at 3 AM. You see, when you actually want to write content, you realize you're stuck creating individual JSX files for each blog post. And trust me, writing blog content in JSX is about as fun as debugging CSS grid in Internet Explorer.
The JSX Nightmare
Here's what writing a blog post in pure JSX looks like:
// This is painful - don't do this
export default function MyBlogPost() {
return (
<div>
<h1>My Amazing Blog Post</h1>
<p>
This is a paragraph with <strong>bold text</strong> and{" "}
<em>italic text</em>. Oh, you want a line break? Too bad...
</p>
</div>
);
}
Yeah, no thanks. I'd rather write CSS without autocomplete.
Enter MDX: The Hero We Deserved
MDX is like Markdown's cooler sibling who went to college and learned React. It lets you write Markdown (which is actually readable) while still being able to embed React components when you need them.
Step 1: Setting Up Your Next.js Project
Initialize the Project
# Create a new Next.js project
npx create-next-app@latest my-blog-app --typescript --tailwind --eslint --app
# Navigate to your project
cd my-blog-app
Install Required Dependencies
# MDX and related packages
npm install @next/mdx @mdx-js/loader @mdx-js/react @types/mdx
# Content processing packages
npm install gray-matter next-mdx-remote
# Optional: For better syntax highlighting
npm install rehype-highlight remark-gfm
Step 2: Configure Next.js for MDX
Update your next.config.ts
:
import type { NextConfig } from 'next';
import createMDX from '@next/mdx';
const nextConfig: NextConfig = {
// Enable MDX file extensions
pageExtensions: ['js', 'jsx', 'md', 'mdx', 'ts', 'tsx'],
// Other Next.js config options
experimental: {
mdxRs: true, // Use faster Rust-based MDX compiler
},
};
// Configure MDX with plugins
const withMDX = createMDX({
options: {
remarkPlugins: [
// Add remark plugins here
],
rehypePlugins: [
// Add rehype plugins here
],
},
});
export default withMDX(nextConfig);
Step 3: Create Your Project Structure
Create this folder structure:
my-blog-app/
├── public/
│ └── images/ # Blog images
├── src/
│ ├── app/
│ │ ├── blog/
│ │ │ ├── [slug]/ # Individual blog post route
│ │ │ │ └── page.tsx # Renders a specific blog post
│ │ │ └── page.tsx # Blog listing page
│ │ ├── components/ # Reusable UI components
│ │ ├── layout.tsx # Root layout component
│ │ ├── not-found.tsx # 404 fallback page
│ │ └── page.tsx # Homepage or root route
├── content/ # MDX blog posts
│ └── my-first-mdx-blog.mdx
├── utils/ # Utility/helper functions
│ ├── blog.ts # Blog post fetching logic
│ └── markdown-parser.tsx # MDX content parser logic
├── mdx-components.tsx # Custom MDX component overrides
├── package.json
Step 4: Configure MDX Components
Create mdx-components.tsx
:
import type { MDXComponents } from "mdx/types"
import Image, { type ImageProps } from "next/image"
import Link from "next/link"
export function useMDXComponents(components: MDXComponents): MDXComponents {
return {
// Headings - customize your styling here
h1: ({ children }) => (
<h1 className="mb-6 font-mono text-3xl font-bold">
{children}
</h1>
),
h2: ({ children }) => (
<h2 className="mb-4 mt-8 font-mono text-2xl font-bold">
{children}
</h2>
),
// Paragraphs
p: ({ children }) => (
<p className="mb-4 leading-relaxed font-mono">
{children}
</p>
),
// Code blocks
pre: ({ children }) => (
<pre className="mb-4 overflow-x-auto rounded-lg bg-gray-900 p-4 text-sm text-green-400">
{children}
</pre>
),
// Inline code
code: ({ children }) => (
<code className="rounded bg-gray-800 px-1 py-0.5 text-sm font-mono text-green-400">
{children}
</code>
),
// Custom components
TLDRBox: ({ children }) => (
<div className="my-6 rounded-lg bg-gray-900 p-4 border border-green-500">
<h4 className="mb-2 font-medium font-mono text-green-400">TL;DR</h4>
<div className="text-green-300 font-mono">{children}</div>
</div>
),
// Add more components as needed...
...components,
}
}
Step 5: Create Blog Utilities
Create utils/blog.ts
:
import fs from 'fs';
import path from 'path';
import matter from 'gray-matter';
// Define your blog post interface
export interface BlogPost {
slug: string;
title: string;
date: string;
author: string;
content: string;
excerpt?: string;
}
// Make sure the path matches the actual location of your content folder.
// If your content folder is in the project root (same level as package.json), use:
const rootDirectory = path.join(process.cwd(), "content");
// I spent hours debugging why my content wasn’t showing up?
// turns out I had placed the folder inside src/content
// So double-check: if your content folder is inside /src, update the path like this instead:
const rootDirectory = path.join(process.cwd(), "src/content");
// Get all blog posts
export function getAllBlogPosts(): BlogPost[] {
// Read all files from content directory
const files = fs.readdirSync(contentDirectory);
const posts = files
.filter((file) => file.endsWith('.mdx'))
.map((file) => {
// Parse frontmatter and content
const slug = file.replace('.mdx', '');
const fullPath = path.join(contentDirectory, file);
const fileContents = fs.readFileSync(fullPath, 'utf8');
const { data, content } = matter(fileContents);
return {
slug,
title: data.title || 'Untitled',
date: data.date || new Date().toISOString(),
author: data.author || 'Anonymous',
content,
excerpt: data.excerpt || content.slice(0, 150) + '...',
};
})
// Sort by date (newest first)
.sort((a, b) => new Date(b.date).getTime() - new Date(a.date).getTime());
return posts;
}
// Get single blog post by slug
export function getBlogPost(slug: string): BlogPost | null {
try {
const fullPath = path.join(contentDirectory, `${slug}.mdx`);
const fileContents = fs.readFileSync(fullPath, 'utf8');
const { data, content } = matter(fileContents);
return {
slug,
title: data.title || 'Untitled',
date: data.date || new Date().toISOString(),
author: data.author || 'Anonymous',
content,
};
} catch {
return null;
}
}
Step 6: Create Markdown Parser
Create utils/markdown-parser.ts
:
import type React from "react";
export function parseMarkdown(content: string): React.ReactNode[] {
const lines = content.split("\n");
const result: React.ReactNode[] = [];
for (let i = 0; i < lines.length; i++) {
const line = lines[i].trim();
if (!line) continue;
if (line.startsWith("# ")) {
result.push(
<h1 key={`h1-${i}`} className="mb-6 font-mono text-3xl font-bold text-white">
{line.slice(2)}
</h1>
);
} else if (line.startsWith("## ")) {
result.push(
<h2 key={`h2-${i}`} className="mb-4 mt-6 font-mono text-2xl font-bold text-white">
{line.slice(3)}
</h2>
);
} else if (line.startsWith("### ")) {
result.push(
<h3 key={`h3-${i}`} className="mb-4 mt-4 font-mono text-xl font-bold text-white">
{line.slice(4)}
</h3>
);
} else {
result.push(
<p key={`p-${i}`} className="mb-4 font-mono leading-relaxed text-gray-200">
{line}
</p>
);
}
}
return result;
}
Step 7: Build Blog Components
Create src/app/components/Blog-Card.tsx
:
import Link from 'next/link';
import { BlogPost } from '@/utils/blog';
interface BlogCardProps {
post: BlogPost;
}
export default function BlogCard({ post }: BlogCardProps) {
return (
<Link href={`/blog/${post.slug}`}>
<article className="group cursor-pointer rounded-lg border p-6 hover:border-green-500">
{/* Blog card title */}
<h2 className="mb-2 font-mono text-xl font-bold group-hover:text-green-500">
{post.title}
</h2>
{/* Metadata */}
<div className="mb-3 flex items-center gap-2 text-sm text-gray-600">
<span>{post.author}</span>
<span>•</span>
<span>{new Date(post.date).toLocaleDateString()}</span>
</div>
{/* Excerpt */}
<p className="font-mono text-sm text-gray-700">
{post.excerpt}
</p>
</article>
</Link>
);
}
Step 8: Create Blog Pages
Blog Listing Page (src/app/blog/page.tsx
)
import { getAllBlogPosts } from '@/utils/blog';
import BlogCard from '@/components/Blog-Card';
export default function BlogPage() {
// Get all blog posts
const posts = getAllBlogPosts();
return (
<div className="container mx-auto px-4 py-8">
{/* Page header */}
<h1 className="mb-8 font-mono text-4xl font-bold">
All Blog Posts
</h1>
{/* Blog posts grid */}
<div className="grid gap-6 md:grid-cols-2 lg:grid-cols-3">
{posts.map((post) => (
<BlogCard key={post.slug} post={post} />
))}
</div>
</div>
);
}
Individual Blog Post Page (src/app/blog/[slug]/page.tsx
)
import { getBlogPost, getAllBlogPosts } from '@/utils/blog';
import { parseMDXContent } from '@/utils/markdown-parser';
import { notFound } from 'next/navigation';
interface BlogPostPageProps {
params: {
slug: string;
};
}
// Generate static params for all blog posts
export async function generateStaticParams() {
const posts = getAllBlogPosts();
return posts.map((post) => ({
slug: post.slug,
}));
}
export default async function BlogPostPage({ params }: BlogPostPageProps) {
// Get the blog post
const post = getBlogPost(params.slug);
// Handle 404 for non-existent posts
if (!post) {
notFound();
}
// Parse MDX content
const content = await parseMDXContent(post.content);
return (
<article className="container mx-auto max-w-4xl px-4 py-8">
{/* Blog post header */}
<header className="mb-8">
<h1 className="mb-4 font-mono text-4xl font-bold">
{post.title}
</h1>
<div className="flex items-center gap-4 text-sm text-gray-600">
<span>By {post.author}</span>
<span>•</span>
<span>{new Date(post.date).toLocaleDateString()}</span>
</div>
</header>
{/* Rendered MDX content */}
<div className="prose prose-lg max-w-none">
{content}
</div>
</article>
);
}
Step 9: Create Your First Blog Post
Create content/my-first-mdx-blog.mdx
:
---
title: "My First MDX Blog Post"
date: "2025-06-01"
author: "Your Name"
excerpt: "Learning MDX is easier than I thought"
---
# Welcome to My Blog
Writing in MDX is really simple! Here's what I like:
* You can write lists easily
Use italic and bold* text without HTML
* Add code blocks quickly
Custom Components
I can even use my custom components:
TL;DR
MDX combines the simplicity of Markdown with the power of React components. It's like having your cake and eating it too, except the cake is code and eating it means shipping to production.
And blockquotes work just like you'd expect them to!
Pretty neat, right?
Step 10: Update Your Root Layout
Update src/app/layout.tsx
:
import type { Metadata } from 'next'
import './globals.css'
export const metadata: Metadata = {
title: 'My Blog',
description: 'A modern blog built with Next.js and MDX',
}
export default function RootLayout({
children,
}: {
children: React.ReactNode
}) {
return (
<html lang="en">
<body>
{/* Add your navigation/header here */}
<main>{children}</main>
{/* Add your footer here */}
</body>
</html>
)
}
Step 11: Add Navigation
Create src/app/components/Header.tsx
:
import Link from 'next/link';
export default function Header() {
return (
<header className="border-b border-gray-200">
<nav className="container mx-auto flex items-center justify-between px-4 py-6">
{/* Logo/Brand */}
<Link href="/" className="font-mono text-2xl font-bold">
My Blog
</Link>
{/* Navigation links */}
<div className="flex items-center space-x-6">
<Link href="/" className="font-mono hover:text-green-500">
Home
</Link>
<Link href="/blog" className="font-mono hover:text-green-500">
Blog
</Link>
</div>
</nav>
</header>
);
}
Step 12: Run and Test
Start Development Server
# Run your Next.js app
npm run dev
Test Your Routes
- Visit
http://localhost:3000
- Homepage - Visit
http://localhost:3000/blog
- Blog listing - Visit
http://localhost:3000/blog/my-first-mdx-blog
- Individual post
Step 13: Build and Deploy
Build for Production
# Create production build
npm run build
# Test production build locally
npm run start
Deploy to Vercel
# Install Vercel CLI
npm i -g vercel
# Deploy
vercel
# Or connect your GitHub repo to Vercel dashboard
Advanced Features You Can Add
Reading Time
// In utils/blog.ts
import readingTime from 'reading-time';
export function getBlogPostWithReadingTime(slug: string) {
const post = getBlogPost(slug);
if (!post) return null;
// Calculate reading time
const stats = readingTime(post.content);
return {
...post,
readingTime: stats.text,
};
}
Search Functionality
// Add to utils/blog.ts
export function searchBlogPosts(query: string): BlogPost[] {
const posts = getAllBlogPosts();
return posts.filter(post =>
post.title.toLowerCase().includes(query.toLowerCase()) ||
post.content.toLowerCase().includes(query.toLowerCase())
);
}
Tags Support
---
title: "My Post"
tags: ["react", "nextjs", "mdx"]
category: "tutorial"
---
Common Issues and Solutions
MDX Components Not Working
Make sure your mdx-components.tsx
file is in the correct location and properly configured.
Build Errors
Check that all your MDX files have proper frontmatter and valid Markdown syntax.
Static Generation Issues
Ensure your generateStaticParams
function returns all possible blog post slugs.
TL;DR
Building a blog with Next.js and MDX: 1) Set up Next.js project with MDX dependencies, 2) Configure MDX components for styling, 3) Create utilities to read MDX files, 4) Build blog listing and individual post pages, 5) Write content in Markdown format. The result? A fast, developer-friendly blog that's easy to maintain.
Conclusion: You Actually Did It!
Congratulations! You've just built a modern blog with Next.js and MDX. Is it more complex than throwing some text in <p>
tags? Sure. But is it infinitely more maintainable, scalable, and pleasant to work with? Absolutely.
The beauty of this setup is that you get the best of both worlds: the simplicity of Markdown for content creation and the power of React for when you need dynamic functionality. Plus, with Next.js handling the static generation, your blog will be lightning fast.
Now go forth and blog! And remember, the best blog is the one you actually write content for, not the one with the most perfect architecture.
Ready to start writing? Create your next MDX file in the content
folder and watch the magic happen!