Profile

How to Build a Modern Blog Website with Next.js and MDX (Without Losing Your Sanity)

June 1, 2025Megh

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:

jsx
// 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

bash
# 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

bash
# 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:

typescript
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:

typescript
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:

typescript
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:

typescript
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:

typescript
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)

typescript
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)

typescript
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:

typescript
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:

typescript
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

bash
# Run your Next.js app
npm run dev

Test Your Routes

  1. Visit http://localhost:3000 - Homepage
  2. Visit http://localhost:3000/blog - Blog listing
  3. Visit http://localhost:3000/blog/my-first-mdx-blog - Individual post

Step 13: Build and Deploy

Build for Production

bash
# Create production build
npm run build

# Test production build locally
npm run start

Deploy to Vercel

bash
# Install Vercel CLI
npm i -g vercel

# Deploy
vercel

# Or connect your GitHub repo to Vercel dashboard

Advanced Features You Can Add

Reading Time

typescript
// 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

typescript
// 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

markdown
---
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!