Build a Blog with Next.js and MDX (The Modern Way)
A step-by-step tutorial to build a blog with Next.js App Router and MDX, covering dynamic routes, syntax highlighting, SEO, RSS feeds, and deployment.
Building a blog with Next.js and MDX is one of those projects that teaches you a surprising amount about modern web development. You'll work with the App Router, file-system conventions, static generation, metadata APIs, and content processing -- all in a project you can actually use.
This tutorial uses Next.js 15 with the App Router, MDX for content, and deploys to Vercel. We're building everything from scratch, not using a starter template, because understanding what every piece does matters more than saving 20 minutes.
Project Setup
npx create-next-app@latest my-blog --typescript --tailwind --eslint --app --src-dir
cd my-blog
Install the MDX dependencies:
npm install @next/mdx @mdx-js/react @mdx-js/loader
npm install gray-matter reading-time
npm install rehype-pretty-code shiki rehype-slug rehype-autolink-headings
npm install rss
Here's what each package does:
@next/mdx,@mdx-js/react,@mdx-js/loader-- MDX processing for Next.jsgray-matter-- Parses YAML frontmatter from MDX filesreading-time-- Estimates reading time from contentrehype-pretty-code-- Syntax highlighting powered by Shikirehype-slug,rehype-autolink-headings-- Adds IDs and links to headingsrss-- Generates RSS feed
Content Directory Structure
Create the content directory:
src/
content/
posts/
hello-world.mdx
building-with-nextjs.mdx
typescript-tips.mdx
Each MDX file has frontmatter at the top and Markdown/JSX content below:
---
title: "Hello, World: My First Blog Post"
description: "Welcome to my blog. Here's what I'm building and why."
date: "2026-03-27"
tags: ["meta", "blogging"]
image: "/images/hello-world.jpg"
This is my first blog post. It supports bold, italic, inline code, and full code blocks:
javascriptfunction greet(name) {
return
Hello, ${name}!;}
And because this is MDX, I can embed React components directly in my content.
The Content Layer
Create a utility that reads MDX files, parses frontmatter, and provides typed data to your components.
// src/lib/content.ts
import fs from 'fs';
import path from 'path';
import matter from 'gray-matter';
import readingTime from 'reading-time';
const POSTS_DIR = path.join(process.cwd(), 'src/content/posts');
export interface PostMeta {
slug: string;
title: string;
description: string;
date: string;
tags: string[];
image?: string;
readingTime: string;
}
export interface Post extends PostMeta {
content: string;
}
export function getAllPostSlugs(): string[] {
return fs
.readdirSync(POSTS_DIR)
.filter((file) => file.endsWith('.mdx'))
.map((file) => file.replace(/\.mdx$/, ''));
}
export function getPostBySlug(slug: string): Post {
const filePath = path.join(POSTS_DIR, ${slug}.mdx);
const fileContent = fs.readFileSync(filePath, 'utf-8');
const { data, content } = matter(fileContent);
return {
slug,
title: data.title,
description: data.description,
date: data.date,
tags: data.tags || [],
image: data.image,
readingTime: readingTime(content).text,
content,
};
}
export function getAllPosts(): PostMeta[] {
const slugs = getAllPostSlugs();
const posts = slugs.map((slug) => {
const post = getPostBySlug(slug);
const { content, ...meta } = post;
return meta;
});
// Sort by date, newest first
return posts.sort(
(a, b) => new Date(b.date).getTime() - new Date(a.date).getTime()
);
}
export function getPostsByTag(tag: string): PostMeta[] {
return getAllPosts().filter((post) =>
post.tags.map((t) => t.toLowerCase()).includes(tag.toLowerCase())
);
}
export function getAllTags(): string[] {
const posts = getAllPosts();
const tagSet = new Set<string>();
posts.forEach((post) => post.tags.forEach((tag) => tagSet.add(tag.toLowerCase())));
return Array.from(tagSet).sort();
}
This is a file-system-based content layer. No database, no CMS, no API. Your content lives in your repo, is version-controlled with Git, and is processed at build time. For a personal or small team blog, this is the ideal setup.
MDX Configuration
Configure Next.js to process MDX files with syntax highlighting:
// next.config.ts
import type { NextConfig } from 'next';
import createMDX from '@next/mdx';
import rehypePrettyCode from 'rehype-pretty-code';
import rehypeSlug from 'rehype-slug';
import rehypeAutolinkHeadings from 'rehype-autolink-headings';
const nextConfig: NextConfig = {
pageExtensions: ['ts', 'tsx', 'md', 'mdx'],
};
const withMDX = createMDX({
options: {
rehypePlugins: [
rehypeSlug,
[rehypeAutolinkHeadings, { behavior: 'wrap' }],
[
rehypePrettyCode,
{
theme: 'one-dark-pro',
keepBackground: true,
},
],
],
},
});
export default withMDX(nextConfig);
Dynamic Routes
The blog post page uses a dynamic route to render any post based on its slug:
// src/app/blog/[slug]/page.tsx
import { notFound } from 'next/navigation';
import { getAllPostSlugs, getPostBySlug } from '@/lib/content';
import { MDXRemote } from '@/components/MDXRemote';
import type { Metadata } from 'next';
interface PageProps {
params: Promise<{ slug: string }>;
}
// Generate all post pages at build time
export async function generateStaticParams() {
return getAllPostSlugs().map((slug) => ({ slug }));
}
// Dynamic metadata for SEO
export async function generateMetadata({ params }: PageProps): Promise<Metadata> {
const { slug } = await params;
const post = getPostBySlug(slug);
if (!post) return {};
return {
title: post.title,
description: post.description,
openGraph: {
title: post.title,
description: post.description,
type: 'article',
publishedTime: post.date,
images: post.image ? [{ url: post.image }] : [],
},
twitter: {
card: 'summary_large_image',
title: post.title,
description: post.description,
},
};
}
export default async function BlogPostPage({ params }: PageProps) {
const { slug } = await params;
try {
const post = getPostBySlug(slug);
return (
<article className="max-w-3xl mx-auto px-4 py-12">
<header className="mb-8">
<h1 className="text-4xl font-bold mb-4">{post.title}</h1>
<div className="flex items-center gap-4 text-gray-500">
<time dateTime={post.date}>
{new Date(post.date).toLocaleDateString('en-US', {
year: 'numeric',
month: 'long',
day: 'numeric',
})}
</time>
<span>{post.readingTime}</span>
</div>
<div className="flex gap-2 mt-4">
{post.tags.map((tag) => (
<a
key={tag}
href={/blog/tag/${tag}}
className="px-3 py-1 bg-gray-100 dark:bg-gray-800 rounded-full text-sm"
>
{tag}
</a>
))}
</div>
</header>
<div className="prose dark:prose-invert max-w-none">
<MDXRemote source={post.content} />
</div>
</article>
);
} catch {
notFound();
}
}
The generateStaticParams function tells Next.js which pages to pre-render at build time. For every slug in your content directory, Next.js generates a static HTML page. No server needed at runtime.
The MDX Renderer
To render MDX content from a string (since we're reading files at build time), we need a component that compiles and renders MDX:
// src/components/MDXRemote.tsx
import { compile, run } from '@mdx-js/mdx';
import * as runtime from 'react/jsx-runtime';
import rehypePrettyCode from 'rehype-pretty-code';
import rehypeSlug from 'rehype-slug';
interface Props {
source: string;
}
export async function MDXRemote({ source }: Props) {
const compiled = await compile(source, {
outputFormat: 'function-body',
rehypePlugins: [
rehypeSlug,
[rehypePrettyCode, { theme: 'one-dark-pro', keepBackground: true }],
],
});
const { default: Content } = await run(String(compiled), {
...runtime,
baseUrl: import.meta.url,
});
return <Content />;
}
Alternatively, if you prefer the next-mdx-remote package for a simpler API:
npm install next-mdx-remote
// src/components/MDXContent.tsx
import { MDXRemote } from 'next-mdx-remote/rsc';
import rehypePrettyCode from 'rehype-pretty-code';
import rehypeSlug from 'rehype-slug';
const components = {
// Custom components available in MDX
Callout: ({ children, type = 'info' }: { children: React.ReactNode; type?: string }) => (
<div className={callout callout-${type}}>{children}</div>
),
};
export function MDXContent({ source }: { source: string }) {
return (
<MDXRemote
source={source}
components={components}
options={{
mdxOptions: {
rehypePlugins: [
rehypeSlug,
[rehypePrettyCode, { theme: 'one-dark-pro' }],
],
},
}}
/>
);
}
The Blog Index Page
// src/app/blog/page.tsx
import { getAllPosts } from '@/lib/content';
import Link from 'next/link';
import type { Metadata } from 'next';
export const metadata: Metadata = {
title: 'Blog',
description: 'Articles about web development, programming, and technology.',
};
export default function BlogPage() {
const posts = getAllPosts();
return (
<div className="max-w-3xl mx-auto px-4 py-12">
<h1 className="text-4xl font-bold mb-8">Blog</h1>
<div className="space-y-8">
{posts.map((post) => (
<article key={post.slug} className="group">
<Link href={/blog/${post.slug}}>
<h2 className="text-2xl font-semibold group-hover:text-blue-500 transition-colors">
{post.title}
</h2>
<p className="text-gray-500 mt-2">{post.description}</p>
<div className="flex items-center gap-4 mt-3 text-sm text-gray-400">
<time dateTime={post.date}>
{new Date(post.date).toLocaleDateString('en-US', {
year: 'numeric',
month: 'long',
day: 'numeric',
})}
</time>
<span>{post.readingTime}</span>
<div className="flex gap-2">
{post.tags.map((tag) => (
<span key={tag} className="px-2 py-0.5 bg-gray-100 dark:bg-gray-800 rounded text-xs">
{tag}
</span>
))}
</div>
</div>
</Link>
</article>
))}
</div>
</div>
);
}
Tag Pages
// src/app/blog/tag/[tag]/page.tsx
import { getAllTags, getPostsByTag } from '@/lib/content';
import Link from 'next/link';
import type { Metadata } from 'next';
interface PageProps {
params: Promise<{ tag: string }>;
}
export async function generateStaticParams() {
return getAllTags().map((tag) => ({ tag }));
}
export async function generateMetadata({ params }: PageProps): Promise<Metadata> {
const { tag } = await params;
return {
title: Posts tagged "${tag}",
description: All blog posts tagged with ${tag},
};
}
export default async function TagPage({ params }: PageProps) {
const { tag } = await params;
const posts = getPostsByTag(tag);
return (
<div className="max-w-3xl mx-auto px-4 py-12">
<h1 className="text-3xl font-bold mb-8">
Posts tagged “{tag}”
</h1>
<div className="space-y-6">
{posts.map((post) => (
<article key={post.slug}>
<Link href={/blog/${post.slug}}>
<h2 className="text-xl font-semibold hover:text-blue-500">
{post.title}
</h2>
<p className="text-gray-500 mt-1">{post.description}</p>
</Link>
</article>
))}
</div>
</div>
);
}
SEO Metadata
Next.js App Router has a powerful metadata API. Set defaults in your root layout:
// src/app/layout.tsx
import type { Metadata } from 'next';
export const metadata: Metadata = {
metadataBase: new URL('https://yourblog.com'),
title: {
default: 'My Blog',
template: '%s | My Blog',
},
description: 'A blog about web development and programming.',
openGraph: {
type: 'website',
locale: 'en_US',
siteName: 'My Blog',
},
twitter: {
card: 'summary_large_image',
creator: '@yourtwitterhandle',
},
alternates: {
types: {
'application/rss+xml': '/feed.xml',
},
},
};
Each page can override or extend these defaults. The template field means a page with title: "My Post" will render as "My Post | My Blog" in the browser tab.
RSS Feed
Generate an RSS feed at build time:
// src/app/feed.xml/route.ts
import { getAllPosts } from '@/lib/content';
import RSS from 'rss';
export async function GET() {
const posts = getAllPosts();
const feed = new RSS({
title: 'My Blog',
description: 'A blog about web development and programming.',
site_url: 'https://yourblog.com',
feed_url: 'https://yourblog.com/feed.xml',
language: 'en',
});
posts.forEach((post) => {
feed.item({
title: post.title,
description: post.description,
url: https://yourblog.com/blog/${post.slug},
date: new Date(post.date),
categories: post.tags,
});
});
return new Response(feed.xml({ indent: true }), {
headers: {
'Content-Type': 'application/xml',
},
});
}
This creates a route at /feed.xml that returns a valid RSS feed. With static export, this generates at build time.
Sitemap
// src/app/sitemap.ts
import { getAllPosts } from '@/lib/content';
import type { MetadataRoute } from 'next';
export default function sitemap(): MetadataRoute.Sitemap {
const posts = getAllPosts();
const postUrls = posts.map((post) => ({
url: https://yourblog.com/blog/${post.slug},
lastModified: new Date(post.date),
changeFrequency: 'monthly' as const,
priority: 0.8,
}));
return [
{
url: 'https://yourblog.com',
lastModified: new Date(),
changeFrequency: 'weekly',
priority: 1,
},
{
url: 'https://yourblog.com/blog',
lastModified: new Date(),
changeFrequency: 'weekly',
priority: 0.9,
},
...postUrls,
];
}
Deploying to Vercel
Push your code to GitHub and connect the repository to Vercel. That's genuinely it. Vercel detects Next.js, runs the build, and deploys.
git add .
git commit -m "Initial blog setup"
git push origin main
Then visit vercel.com, import the repository, and click Deploy. Every push to main will trigger a new deployment.
For custom domains, add them in the Vercel dashboard. DNS propagation takes a few minutes, and Vercel automatically provisions an SSL certificate.
Common Mistakes
Not usinggenerateStaticParams. Without it, your blog post pages aren't pre-rendered at build time. They'll work in development but fail with a static export.
Forgetting to sort posts by date. The file system doesn't guarantee order. Always sort explicitly in your content utility.
Not handling missing posts. If someone navigates to /blog/nonexistent-slug, your page should call notFound() instead of crashing with an unhandled file read error.
Overcomplicating the content layer. For most blogs, reading files from disk and parsing frontmatter is enough. You don't need a headless CMS, a database, or a content API until you have 500+ posts or multiple content editors.
Skipping the RSS feed. RSS is how technical readers follow blogs. It takes 20 lines of code and dramatically increases your reach among the developer audience.
What's Next
You now have a fully functional, statically generated blog with MDX content, syntax highlighting, SEO metadata, tag pages, RSS, and a sitemap. From here, you might add full-text search (Pagefind is excellent for static sites), a newsletter signup, reading progress indicators, related posts, or dark mode support.
The beauty of this architecture is that adding a new post is just creating a new MDX file and pushing to Git. No CMS login, no database migration, no deployment configuration. Write, push, done.
Explore more web development tutorials and build real projects at CodeUp.