MDX Notes
Build a real blog with Next.js and MDX — dynamic routes, pagination, tags, RSS feeds, and reading time. Full working code included.
MDX is a great fit for blog content. You get the simplicity of Markdown for writing, plus React components for anything interactive. This guide walks you through building a real blog — dynamic routes, pagination, tags, RSS, reading time, the works.
Project Structure
Here's what a well-organized MDX blog looks like:
title: "Getting Started with MDX" description: "Learn how MDX combines Markdown with JSX for powerful content authoring" date: "2024-01-15" author: "Jane Developer" tags: ["mdx", "tutorial", "beginner"] category: "tutorials" image: "/images/blog/mdx-intro.jpg" published: true
Getting Started with MDX
Welcome to this comprehensive guide on MDX...
<Callout type="tip"> MDX lets you use JSX in your markdown content. </Callout>
What You'll Learn
- How MDX works under the hood
- Setting up your first MDX file
- Using components in your content
// lib/mdx.ts import fs from 'fs' import path from 'path' import matter from 'gray-matter' import { cache } from 'react'
const POSTS_DIR = path.join(process.cwd(), 'content', 'posts')
export interface PostFrontmatter { title: string description: string date: string author: string tags: string[] category: string image?: string published: boolean }
export interface Post { slug: string frontmatter: PostFrontmatter content: string }
// Cache the post retrieval for React Server Components export const getAllPosts = cache((): Post[] => { const files = fs.readdirSync(POSTS_DIR)
const posts = files .filter((file) => file.endsWith('.mdx')) .map((file) => { const slug = file.replace('.mdx', '') const filePath = path.join(POSTS_DIR, file) const source = fs.readFileSync(filePath, 'utf-8') const { data, content } = matter(source)
return { slug, frontmatter: data as PostFrontmatter, content, } }) .filter((post) => post.frontmatter.published) .sort( (a, b) => new Date(b.frontmatter.date).getTime() - new Date(a.frontmatter.date).getTime() )
return posts })
export function getPostBySlug(slug: string): Post | undefined { return getAllPosts().find((post) => post.slug === slug) }
export function getPostsByTag(tag: string): Post[] { return getAllPosts().filter((post) => post.frontmatter.tags.includes(tag) ) }
export function getPostsByCategory(category: string): Post[] { return getAllPosts().filter( (post) => post.frontmatter.category === category ) }
export function getAllTags(): string[] { const tags = new Set<string>() getAllPosts().forEach((post) => { post.frontmatter.tags.forEach((tag) => tags.add(tag)) }) return Array.from(tags).sort() }
// Pagination helper export function getPaginatedPosts(page: number, perPage: number = 10) { const allPosts = getAllPosts() const totalPages = Math.ceil(allPosts.length / perPage) const start = (page - 1) * perPage const posts = allPosts.slice(start, start + perPage)
return { posts, totalPages, currentPage: page, hasNext: page < totalPages, hasPrev: page > 1, } }
// app/blog/[slug]/page.tsx import { MDXRemote } from 'next-mdx-remote/rsc' import { notFound } from 'next/navigation' import { getAllPosts, getPostBySlug } from '@/lib/mdx' import { calculateReadingTime } from '@/lib/reading-time' import { Metadata } from 'next'
interface Props { params: { slug: string } }
// Generate static params at build time export async function generateStaticParams() { const posts = getAllPosts() return posts.map((post) => ({ slug: post.slug, })) }
// Dynamic metadata from frontmatter export async function generateMetadata({ params }: Props): Promise<Metadata> { const post = getPostBySlug(params.slug) if (!post) return { title: 'Not Found' }
return { title: post.frontmatter.title, description: post.frontmatter.description, openGraph: { title: post.frontmatter.title, description: post.frontmatter.description, images: post.frontmatter.image ? [post.frontmatter.image] : [], type: 'article', publishedTime: post.frontmatter.date, authors: [post.frontmatter.author], tags: post.frontmatter.tags, }, } }
export default function BlogPost({ params }: Props) { const post = getPostBySlug(params.slug) if (!post) notFound()
const readingTime = calculateReadingTime(post.content)
return ( <article className="max-w-3xl mx-auto py-12"> <header className="mb-8"> <h1 className="text-4xl font-bold mb-4"> {post.frontmatter.title} </h1> <div className="flex items-center gap-4 text-gray-600"> <time dateTime={post.frontmatter.date}> {new Date(post.frontmatter.date).toLocaleDateString('en-US', { year: 'numeric', month: 'long', day: 'numeric', })} </time> <span>·</span> <span>{readingTime} min read</span> <span>·</span> <span>{post.frontmatter.author}</span> </div> <div className="flex gap-2 mt-4"> {post.frontmatter.tags.map((tag) => ( <a key={tag} href={/blog/tag/${tag}} className="px-3 py-1 bg-blue-100 text-blue-800 rounded-full text-sm" > {tag} </a> ))} </div> </header>
<div className="prose prose-lg"> <MDXRemote source={post.content} /> </div> </article> ) }
// app/blog/page.tsx import { getPaginatedPosts, getAllTags } from '@/lib/mdx' import BlogCard from '@/components/BlogCard' import Pagination from '@/components/Pagination' import TagList from '@/components/TagList'
interface Props { searchParams: { page?: string; tag?: string } }
export default function BlogIndex({ searchParams }: Props) { const page = parseInt(searchParams.page || '1', 10) const { posts, totalPages, currentPage, hasNext, hasPrev } = getPaginatedPosts(page, 10) const allTags = getAllTags()
return ( <div className="max-w-6xl mx-auto py-12"> <h1 className="text-4xl font-bold mb-8">Blog</h1>
{/* Tag Filter */} <TagList tags={allTags} activeTag={searchParams.tag} />
{/* Post Grid */} <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6 mt-8"> {posts.map((post) => ( <BlogCard key={post.slug} post={post} /> ))} </div>
{/* Pagination */} <Pagination currentPage={currentPage} totalPages={totalPages} hasNext={hasNext} hasPrev={hasPrev} basePath="/blog" /> </div> ) }
// components/Pagination.tsx import Link from 'next/link'
interface PaginationProps { currentPage: number totalPages: number hasNext: boolean hasPrev: boolean basePath: string }
export default function Pagination({ currentPage, totalPages, hasNext, hasPrev, basePath, }: PaginationProps) { return ( <nav className="flex justify-center items-center gap-4 mt-12"> {hasPrev && ( <Link href={${basePath}?page=${currentPage - 1}} className="px-4 py-2 bg-gray-200 rounded hover:bg-gray-300" > ← Previous </Link> )}
<span className="text-gray-600"> Page {currentPage} of {totalPages} </span>
{hasNext && ( <Link href={${basePath}?page=${currentPage + 1}} className="px-4 py-2 bg-gray-200 rounded hover:bg-gray-300" > Next → </Link> )} </nav> ) }
// lib/reading-time.ts export function calculateReadingTime(content: string): number { // Average reading speed: 200-250 words per minute const WORDS_PER_MINUTE = 225
// Remove MDX/JSX components and code blocks for accurate count const cleanContent = content .replace(/<[^>]*>/g, '') // Remove JSX tags .replace(/``[\s\S]*?`/g, '') // Remove code blocks .replace(/[^]*/g, '') // Remove inline code .replace(/\[([^\]]*)\]\([^)]*\)/g, '$1') // Simplify links .replace(/[#*_~]/g, '') // Remove markdown syntax
const words = cleanContent.trim().split(/\s+/).length const minutes = Math.ceil(words / WORDS_PER_MINUTE)
return Math.max(1, minutes) // Minimum 1 minute }
// app/blog/tag/[tag]/page.tsx import { getPostsByTag, getAllTags } from '@/lib/mdx' import BlogCard from '@/components/BlogCard' import { notFound } from 'next/navigation' import { Metadata } from 'next'
interface Props { params: { tag: string } }
export async function generateStaticParams() { const tags = getAllTags() return tags.map((tag) => ({ tag })) }
export async function generateMetadata({ params }: Props): Promise<Metadata> { return { title: Posts tagged "${params.tag}", description: All blog posts tagged with ${params.tag}, } }
export default function TagPage({ params }: Props) { const posts = getPostsByTag(params.tag) if (posts.length === 0) notFound()
return ( <div className="max-w-6xl mx-auto py-12"> <h1 className="text-3xl font-bold mb-2"> Tag: <span className="text-blue-600">#{params.tag}</span> </h1> <p className="text-gray-600 mb-8"> {posts.length} post{posts.length !== 1 ? 's' : ''} found </p>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6"> {posts.map((post) => ( <BlogCard key={post.slug} post={post} /> ))} </div> </div> ) }
// app/feed.xml/route.ts import { getAllPosts } from '@/lib/mdx'
const SITE_URL = 'https://yourblog.com'
export async function GET() { const posts = getAllPosts()
const rssItems = posts .map( (post) => <item> <title><![CDATA[${post.frontmatter.title}]]></title> <description><![CDATA[${post.frontmatter.description}]]></description> <link>${SITE_URL}/blog/${post.slug}</link> <guid isPermaLink="true">${SITE_URL}/blog/${post.slug}</guid> <pubDate>${new Date(post.frontmatter.date).toUTCString()}</pubDate> ${post.frontmatter.tags .map((tag) => <category>${tag}</category>) .join('\n ')} <author>${post.frontmatter.author}</author> </item> ) .join('')
const rss = <?xml version="1.0" encoding="UTF-8"?> <rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom"> <channel> <title>My MDX Blog</title> <description>A blog built with Next.js and MDX</description> <link>${SITE_URL}</link> <atom:link href="${SITE_URL}/feed.xml" rel="self" type="application/rss+xml"/> <language>en-us</language> <lastBuildDate>${new Date().toUTCString()}</lastBuildDate> ${rssItems} </channel> </rss>
return new Response(rss, { headers: { 'Content-Type': 'application/xml', 'Cache-Control': 's-maxage=3600, stale-while-revalidate', }, }) }
// pages/blog/[slug].tsx (Pages Router) import { GetStaticPaths, GetStaticProps } from 'next' import { serialize } from 'next-mdx-remote/serialize' import { MDXRemote } from 'next-mdx-remote' import { getAllPosts, getPostBySlug } from '@/lib/mdx' import { calculateReadingTime } from '@/lib/reading-time'
export const getStaticPaths: GetStaticPaths = async () => { const posts = getAllPosts() return { paths: posts.map((post) => ({ params: { slug: post.slug } })), fallback: false, } }
export const getStaticProps: GetStaticProps = async ({ params }) => { const post = getPostBySlug(params!.slug as string) if (!post) return { notFound: true }
const mdxSource = await serialize(post.content, { mdxOptions: { remarkPlugins: [], rehypePlugins: [], }, })
return { props: { source: mdxSource, frontmatter: post.frontmatter, readingTime: calculateReadingTime(post.content), }, } }
Exam Focus
Revise definitions, diagrams, examples, and short-answer points for Building a Blog with Next.js and MDX.
Interview Use
Prepare one clear explanation, one practical example, and one common mistake for this MDX Tutorial topic.
Search Terms
mdx-tutorial, mdx tutorial, mdx, tutorial, nextjs, blog, building a blog with next.js and mdx
Related MDX Tutorial Topics