DBMS Topics
Building a Blog with Next.js and MDX
title: "Building a Blog with Next.js and MDX",
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 topic.
Search Terms
mdx, mdx tutorial, learn mdx, markdown, jsx, tutorial, nextjs, blog
Related MDX Topics