Build a Blog with Next.js and MDX
A hands-on tutorial for building a statically generated blog with Next.js App Router, MDX, and Tailwind CSS.

When it comes to building a blog, options are everywhere. WordPress, Ghost, Hashnode, or roll your own. For developers, the DIY route is tempting — and Next.js + MDX is one of the most appealing combos out there.
The pitch: each MDX file is a blog post. Write in Markdown, embed React components when you need them. No CMS, no database. Files on disk, built into static pages at compile time. Fast to write, fast to serve.
This walkthrough covers building an MDX blog from scratch using Next.js App Router.
Project Setup
Start with a fresh Next.js project.
npx create-next-app@latest my-blog --typescript --tailwind --app --src-dir
cd my-blog
Install the MDX processing packages.
npm install gray-matter next-mdx-remote remark-gfm rehype-pretty-code shiki
What each does:
- gray-matter — Parses frontmatter (metadata) from MDX files
- next-mdx-remote — Converts MDX strings into React components
- remark-gfm — Adds GitHub Flavored Markdown support (tables, checklists)
- rehype-pretty-code — Syntax highlighting for code blocks
- shiki — The highlighting engine behind rehype-pretty-code
Content Structure
Create a content/blog folder at the project root for your MDX files.
my-blog/
├── content/
│ └── blog/
│ ├── first-post.mdx
│ └── second-post.mdx
├── src/
│ └── app/
│ ├── blog/
│ │ ├── page.tsx ← post list
│ │ └── [slug]/
│ │ └── page.tsx ← individual post
│ └── page.tsx
└── ...
An MDX file looks like this:
---
title: "My First Post"
description: "This is the first blog post."
date: "2026-01-01"
tags: ["blog", "getting-started"]
---
Body content starts here. Standard Markdown syntax works.
## Subheadings work
Code blocks too:
\`\`\`typescript
const greeting = "Hello world";
\`\`\`
**Bold**, *italic*, [links](https://example.com) — all supported.
Everything between the --- markers is frontmatter — metadata about the post (title, description, date).
Parsing Frontmatter
Use gray-matter to separate metadata from content.
// src/lib/blog.ts
import fs from 'fs';
import path from 'path';
import matter from 'gray-matter';
const BLOG_DIR = path.join(process.cwd(), 'content/blog');
export interface BlogPost {
slug: string;
title: string;
description: string;
date: string;
tags: string[];
content: string;
}
export function getAllPosts(): BlogPost[] {
const files = fs.readdirSync(BLOG_DIR);
const posts = files
.filter((file) => file.endsWith('.mdx'))
.map((file) => {
const slug = file.replace('.mdx', '');
const raw = fs.readFileSync(path.join(BLOG_DIR, file), 'utf-8');
const { data, content } = matter(raw);
return {
slug,
title: data.title,
description: data.description,
date: data.date,
tags: data.tags || [],
content,
};
})
.sort((a, b) => new Date(b.date).getTime() - new Date(a.date).getTime());
return posts;
}
export function getPostBySlug(slug: string): BlogPost | undefined {
const filePath = path.join(BLOG_DIR, `${slug}.mdx`);
if (!fs.existsSync(filePath)) return undefined;
const raw = fs.readFileSync(filePath, 'utf-8');
const { data, content } = matter(raw);
return {
slug,
title: data.title,
description: data.description,
date: data.date,
tags: data.tags || [],
content,
};
}
getAllPosts returns every post sorted newest-first. getPostBySlug fetches a single post by filename.
Post List Page
// src/app/blog/page.tsx
import Link from 'next/link';
import { getAllPosts } from '@/lib/blog';
export default function BlogListPage() {
const posts = getAllPosts();
return (
<main className="max-w-2xl mx-auto px-4 py-12">
<h1 className="text-3xl font-bold mb-8">Blog</h1>
<ul className="space-y-6">
{posts.map((post) => (
<li key={post.slug}>
<Link href={`/blog/${post.slug}`} className="group block">
<time className="text-sm text-gray-500">{post.date}</time>
<h2 className="text-xl font-semibold group-hover:text-blue-600 transition-colors">
{post.title}
</h2>
<p className="text-gray-600 mt-1">{post.description}</p>
</Link>
</li>
))}
</ul>
</main>
);
}
This is a Server Component — no "use client" needed. It reads the filesystem at build time and renders statically. Zero runtime cost.
Individual Post Page
This is where the real work happens. The MDX string needs to be compiled into React components.
// src/app/blog/[slug]/page.tsx
import { notFound } from 'next/navigation';
import { MDXRemote } from 'next-mdx-remote/rsc';
import remarkGfm from 'remark-gfm';
import rehypePrettyCode from 'rehype-pretty-code';
import { getAllPosts, getPostBySlug } from '@/lib/blog';
export function generateStaticParams() {
return getAllPosts().map((post) => ({ slug: post.slug }));
}
export default async function BlogPostPage({
params,
}: {
params: Promise<{ slug: string }>;
}) {
const { slug } = await params;
const post = getPostBySlug(slug);
if (!post) notFound();
return (
<article className="max-w-2xl mx-auto px-4 py-12">
<header className="mb-8">
<time className="text-sm text-gray-500">{post.date}</time>
<h1 className="text-3xl font-bold mt-2">{post.title}</h1>
<p className="text-gray-600 mt-2">{post.description}</p>
</header>
<div className="prose prose-lg dark:prose-invert">
<MDXRemote
source={post.content}
options={{
mdxOptions: {
remarkPlugins: [remarkGfm],
rehypePlugins: [
[rehypePrettyCode, { theme: 'github-dark' }],
],
},
}}
/>
</div>
</article>
);
}
next-mdx-remote/rsc renders MDX in Server Components. generateStaticParams pre-generates all slug routes for static builds.
The prose class comes from Tailwind Typography — it applies sensible typography defaults to the HTML that MDX produces. Without it, headings, paragraphs, and lists look unstyled.
npm install @tailwindcss/typography
Custom MDX Components
MDX's real power is embedding React components inside Markdown. You can override default elements or add entirely new ones.
// src/components/mdx/Callout.tsx
interface CalloutProps {
type?: 'info' | 'warning' | 'error';
children: React.ReactNode;
}
export function Callout({ type = 'info', children }: CalloutProps) {
const styles = {
info: 'bg-blue-50 border-blue-500 dark:bg-blue-950',
warning: 'bg-yellow-50 border-yellow-500 dark:bg-yellow-950',
error: 'bg-red-50 border-red-500 dark:bg-red-950',
};
return (
<div className={`border-l-4 p-4 my-4 rounded-r ${styles[type]}`}>
{children}
</div>
);
}
Pass it to MDXRemote via the components prop:
<MDXRemote
source={post.content}
components={{ Callout }}
options={{ /* ... */ }}
/>
Then use it in any MDX file:
<Callout type="warning">
This is a warning. Pay attention to this section.
</Callout>
SEO
A blog without SEO is a blog nobody reads.
Metadata
Next.js App Router uses generateMetadata for per-page meta tags.
// add to src/app/blog/[slug]/page.tsx
export async function generateMetadata({ params }: { params: Promise<{ slug: string }> }) {
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,
},
};
}
Sitemap
Create app/sitemap.ts and Next.js automatically serves /sitemap.xml.
// src/app/sitemap.ts
import { getAllPosts } from '@/lib/blog';
export default function sitemap() {
const posts = getAllPosts().map((post) => ({
url: `https://yourdomain.com/blog/${post.slug}`,
lastModified: new Date(post.date),
}));
return [
{ url: 'https://yourdomain.com', lastModified: new Date() },
{ url: 'https://yourdomain.com/blog', lastModified: new Date() },
...posts,
];
}
RSS Feed
RSS still has a dedicated audience. Create a Route Handler at app/feed.xml/route.ts using the rss package.
Styling with Tailwind
@tailwindcss/typography handles base styles through the prose class, but you can customize further:
/* custom code block styles */
.prose pre {
@apply rounded-lg border border-gray-200 dark:border-gray-700;
}
.prose code {
@apply text-sm;
}
/* inline code */
.prose :not(pre) > code {
@apply bg-gray-100 dark:bg-gray-800 px-1.5 py-0.5 rounded text-sm;
}
Dark mode is handled by dark:prose-invert for the basics. Fine-tune individual elements with dark: variants.
Deploying to Vercel
Next.js + Vercel is the path of least resistance.
npm i -g vercel
vercel
Connect a GitHub repo to Vercel and every push triggers an automatic deploy. PRs get preview URLs for checking changes before they go live.
Since MDX files are read at build time and compiled to static pages, even hundreds of posts won't slow down page loads — there's no server processing at runtime.
Where to Go From Here
Once the foundation is solid, consider adding:
- Category/tag filtering. Parse frontmatter tags and generate per-tag listing pages.
- Search. Fuse.js provides client-side fuzzy search with no backend needed.
- Table of contents. Parse MDX headings and auto-generate a sidebar TOC.
- Reading time. Estimate based on word count (roughly 200–250 words per minute for English).
- OG image generation. Use
@vercel/ogto dynamically create Open Graph images from post titles.
The beauty of an MDX blog is its simplicity. No database, no CMS. One file equals one post. Version-controlled with Git, written in your editor, deployed on push. For a developer, that's about as natural a writing workflow as it gets.