← Back to articles

How to Build a Blog with MDX and Next.js (2026)

MDX lets you write blog posts in Markdown with React components. Combined with Next.js, you get a fast, SEO-friendly blog. Here's the complete setup.

Project Setup

npx create-next-app@latest my-blog
cd my-blog
npm install @next/mdx @mdx-js/react gray-matter reading-time
npm install rehype-pretty-code shiki

Configure MDX

// next.config.mjs
import createMDX from '@next/mdx'

const withMDX = createMDX({
  options: {
    remarkPlugins: [],
    rehypePlugins: [
      ['rehype-pretty-code', { theme: 'one-dark-pro' }],
    ],
  },
})

export default withMDX({
  pageExtensions: ['ts', 'tsx', 'md', 'mdx'],
})

Content Structure

content/
├── hello-world.mdx
├── nextjs-tips.mdx
└── react-patterns.mdx

Blog Post Format

---
title: "Getting Started with Next.js"
description: "A complete guide to Next.js for beginners"
date: "2026-03-12"
tags: ["nextjs", "react", "tutorial"]
image: "/images/nextjs-guide.png"
---

# Getting Started with Next.js

Next.js is the React framework for production...

<Callout type="info">
  This is a custom React component inside MDX!
</Callout>

```typescript
export default function Home() {
  return <h1>Hello Next.js</h1>
}

## Content Loader

```typescript
// lib/blog.ts
import fs from 'fs'
import path from 'path'
import matter from 'gray-matter'
import { cache } from 'react'

const contentDir = path.join(process.cwd(), 'content')

export const getPosts = cache(async () => {
  const files = fs.readdirSync(contentDir).filter(f => f.endsWith('.mdx'))

  const posts = files.map(file => {
    const raw = fs.readFileSync(path.join(contentDir, file), 'utf8')
    const { data, content } = matter(raw)
    const slug = file.replace('.mdx', '')

    return {
      slug,
      title: data.title,
      description: data.description,
      date: data.date,
      tags: data.tags || [],
      image: data.image,
      content,
    }
  })

  return posts.sort((a, b) => new Date(b.date).getTime() - new Date(a.date).getTime())
})

export async function getPost(slug: string) {
  const posts = await getPosts()
  return posts.find(p => p.slug === slug)
}

Blog List Page

// app/blog/page.tsx
import { getPosts } from '@/lib/blog'
import Link from 'next/link'

export default async function BlogPage() {
  const posts = await getPosts()

  return (
    <div className="max-w-2xl mx-auto py-16">
      <h1 className="text-3xl font-bold mb-8">Blog</h1>
      {posts.map(post => (
        <article key={post.slug} className="mb-8">
          <Link href={`/blog/${post.slug}`}>
            <h2 className="text-xl font-semibold hover:text-blue-600">{post.title}</h2>
          </Link>
          <p className="text-gray-600 mt-1">{post.description}</p>
          <time className="text-sm text-gray-400">{post.date}</time>
        </article>
      ))}
    </div>
  )
}

Blog Post Page

// app/blog/[slug]/page.tsx
import { getPosts, getPost } from '@/lib/blog'
import { MDXRemote } from 'next-mdx-remote/rsc'
import { notFound } from 'next/navigation'

export async function generateStaticParams() {
  const posts = await getPosts()
  return posts.map(post => ({ slug: post.slug }))
}

export async function generateMetadata({ params }) {
  const post = await getPost(params.slug)
  if (!post) return {}
  return {
    title: post.title,
    description: post.description,
    openGraph: { title: post.title, description: post.description, images: [post.image] },
  }
}

export default async function PostPage({ params }) {
  const post = await getPost(params.slug)
  if (!post) notFound()

  return (
    <article className="max-w-2xl mx-auto py-16 prose dark:prose-invert">
      <h1>{post.title}</h1>
      <time className="text-gray-400">{post.date}</time>
      <MDXRemote source={post.content} components={mdxComponents} />
    </article>
  )
}

Custom MDX Components

const mdxComponents = {
  Callout: ({ children, type = 'info' }) => (
    <div className={`p-4 rounded-lg border ${type === 'info' ? 'bg-blue-50 border-blue-200' : 'bg-yellow-50 border-yellow-200'}`}>
      {children}
    </div>
  ),
  img: (props) => <Image {...props} width={800} height={400} className="rounded-lg" />,
}

SEO: RSS Feed + Sitemap

// app/feed.xml/route.ts
export async function GET() {
  const posts = await getPosts()
  const xml = `<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0"><channel>
<title>My Blog</title>
<link>https://myblog.com</link>
${posts.map(p => `<item>
<title>${p.title}</title>
<link>https://myblog.com/blog/${p.slug}</link>
<pubDate>${new Date(p.date).toUTCString()}</pubDate>
<description>${p.description}</description>
</item>`).join('')}
</channel></rss>`

  return new Response(xml, { headers: { 'Content-Type': 'application/xml' } })
}

FAQ

MDX vs plain Markdown?

MDX lets you embed React components (interactive demos, callouts, charts). Use MDX if you want interactivity. Plain Markdown for simpler blogs.

Next.js blog vs Astro?

Next.js if your blog is part of a larger app. Astro for a standalone blog (zero JS by default, faster).

Where should I host?

Vercel (free for personal). Cloudflare Pages (free). Both handle Next.js static blogs perfectly.

Bottom Line

Next.js + MDX = a fast, SEO-friendly blog with React component support. Use gray-matter for frontmatter, rehype-pretty-code for syntax highlighting, and deploy to Vercel. The setup takes ~1 hour and you get a blog that's infinitely customizable.

Get AI tool guides in your inbox

Weekly deep-dives on the best AI coding tools, automation platforms, and productivity software.