Build a Personal Blog — Phase 2: MDX On-Demand Rendering

This is Phase 2 of the Build a Personal Blog series. You already have a running Next.js 16 skeleton with ShadCN/UI from Phase 1. Now you'll add the content layer — MDX files on disk that are compiled on every request and rendered as full React pages with syntax-highlighted code blocks.
Series: Build a Personal Blog — Complete Roadmap
Previous: Phase 1 — Next.js 16 + ShadCN/UI Setup
Next: Phase 3 — PostgreSQL + Drizzle ORM (Coming Soon)
What is MDX?
MDX = Markdown + JSX. You write standard Markdown (headings, code blocks, lists, links) and can drop in React components anywhere:
# Hello World
This is a regular paragraph.
<CustomAlert type="warning">
This is a React component inside Markdown!
</CustomAlert>
```bash
npm install next-mdx-remote
```Why MDX for a blog?
- Familiar syntax — write content in Markdown, no CMS dashboard needed
- Full React power — embed interactive charts, callout boxes, tabs, anything
- Git-based workflow — content lives in your repo, version-controlled alongside code
- Portable —
.mdxfiles work with any MDX-compatible tool or framework
On-Demand Rendering vs Static Generation
Next.js gives you two main strategies for rendering content:
| Feature | Static Generation (SSG) | On-Demand (Server) |
|---|---|---|
| When compiled | At build time | At request time |
| Content updates | Requires rebuild | Instant — edit file, refresh |
| Build time | Grows with post count | Constant |
| Best for | Marketing sites, docs | Blogs with frequent edits |
| Next.js API | generateStaticParams() | export const dynamic = 'force-dynamic' |
We'll use on-demand rendering because:
- Zero rebuild — edit an MDX file, refresh the browser, see the change
- Simpler deploy — no need to rebuild the entire site when you publish a new post
- Perfect for self-hosted — your VPS has plenty of power to compile MDX per request
- Easy switch later — you can always add
generateStaticParams()for SSG if needed
Install Dependencies
You need four packages for the MDX pipeline:
npm install next-mdx-remote gray-matter remark-gfm rehype-pretty-code rehype-slug shiki| Package | Purpose |
|---|---|
next-mdx-remote | Compile and render MDX in React Server Components |
gray-matter | Parse YAML frontmatter (title, date, tags) from MDX files |
remark-gfm | GitHub Flavored Markdown — tables, task lists, strikethrough |
rehype-pretty-code | Syntax highlighting for code blocks (powered by Shiki) |
rehype-slug | Auto-generate id attributes on headings for anchor links |
shiki | The syntax highlighting engine behind rehype-pretty-code |
Content Directory Structure
Create the directory where all your MDX posts will live:
mkdir -p content/postsYour project should now look like:
my-blog/
├── app/
│ ├── layout.tsx
│ ├── page.tsx
│ └── blog/
│ ├── page.tsx # Blog listing
│ └── [slug]/
│ └── page.tsx # Individual post
├── components/
│ ├── Header.tsx
│ └── Footer.tsx
├── content/
│ └── posts/ # MDX files go here
│ ├── hello-world.mdx
│ └── my-second-post.mdx
├── lib/
│ ├── mdx.ts # MDX compilation config
│ └── posts.ts # Read/list posts from disk
└── package.jsonNote: In a later phase we'll add bilingual support with
content/posts/en/andcontent/posts/vi/subdirectories. For now we keep it simple with a single flat folder.
Write Your First MDX Post
Create content/posts/hello-world.mdx:
---
title: "Hello World — My First Blog Post"
description: "A short test post to verify our MDX pipeline works end-to-end."
date: "2026-02-28"
tags: ["intro", "blog"]
---
Welcome to my blog! This post proves that the MDX pipeline is working.
## Why I Built This
I wanted a blog that:
- Lives in my Git repo
- Uses Markdown for writing
- Supports code highlighting out of the box
## Code Example
Here's a simple TypeScript function:
```typescript
function greet(name: string): string {
return `Hello, ${name}!`
}
console.log(greet('World'))
```
## What's Next
In the next phase we'll add a database with Drizzle ORM for view counts and subscriptions.Frontmatter Explained
The YAML block between the --- fences is frontmatter — metadata about the post:
---
title: "Hello World" # Displayed as the page <h1>
description: "A short test..." # Used in <meta> tags and post cards
date: "2026-02-28" # Publication date (YYYY-MM-DD)
tags: ["intro", "blog"] # For filtering and display
---gray-matter parses this into a JavaScript object, separating it from the MDX content body.
Define the Post TypeScript Types
Create lib/posts.ts — this file handles reading MDX files from disk and exposing typed post data:
// lib/posts.ts
import fs from 'fs'
import path from 'path'
import matter from 'gray-matter'
// TypeScript interface for post metadata
export interface PostMeta {
title: string
description: string
date: string
tags: string[]
image?: string
slug: string
}
// Full post = metadata + raw MDX content
export interface Post extends PostMeta {
content: string
}These types give you autocomplete and type checking everywhere you handle post data.
Read All Posts from Disk
Add the getAllPosts() function to lib/posts.ts:
// lib/posts.ts (continued)
const POSTS_DIR = path.join(process.cwd(), 'content/posts')
function formatDate(date: Date | string): string {
const d = new Date(date)
return d.toLocaleDateString('en-US', {
year: 'numeric',
month: 'long',
day: 'numeric',
})
}
export function getAllPosts(): PostMeta[] {
// 1. Read all .mdx filenames from the posts directory
const filenames = fs.readdirSync(POSTS_DIR)
.filter((f) => f.endsWith('.mdx'))
// 2. Parse frontmatter from each file
const posts = filenames.map((filename) => {
const slug = filename.replace(/\.mdx$/, '')
const fullPath = path.join(POSTS_DIR, filename)
const fileContents = fs.readFileSync(fullPath, 'utf8')
const { data } = matter(fileContents)
return {
slug,
title: data.title,
description: data.description,
date: formatDate(data.date),
tags: data.tags || [],
image: data.image,
}
})
// 3. Sort by date (newest first)
return posts.sort((a, b) =>
new Date(b.date).getTime() - new Date(a.date).getTime()
)
}How it works:
- Read directory —
fs.readdirSync()lists all.mdxfiles - Parse each file —
gray-matter()separates frontmatter from content - Build metadata — extract
title,description,date,tagsfrom frontmatter - Sort by date — newest posts appear first
Read a Single Post by Slug
Add getPostBySlug() to the same file:
// lib/posts.ts (continued)
export function getPostBySlug(slug: string): Post | null {
const fullPath = path.join(POSTS_DIR, `${slug}.mdx`)
// Return null if the file doesn't exist
if (!fs.existsSync(fullPath)) {
return null
}
const fileContents = fs.readFileSync(fullPath, 'utf8')
const { data, content } = matter(fileContents)
return {
slug,
title: data.title,
description: data.description,
date: formatDate(data.date),
tags: data.tags || [],
image: data.image,
content, // Raw MDX string — will be compiled later
}
}The key difference: getPostBySlug() also returns the content field — the raw MDX string that we'll compile into React components.
Configure the MDX Compiler
Create lib/mdx.ts — this is where you wire up all the remark/rehype plugins:
// lib/mdx.ts
import { compileMDX } from 'next-mdx-remote/rsc'
import rehypePrettyCode from 'rehype-pretty-code'
import rehypeSlug from 'rehype-slug'
import remarkGfm from 'remark-gfm'
// rehype-pretty-code options (Shiki-powered syntax highlighting)
const prettyCodeOptions = {
theme: 'github-dark', // Dark theme for code blocks
keepBackground: true, // Preserve the theme's background color
defaultLang: 'plaintext', // Fallback language when none is specified
}
export async function compileMDXContent(source: string) {
const { content } = await compileMDX({
source,
options: {
parseFrontmatter: true,
mdxOptions: {
remarkPlugins: [remarkGfm],
rehypePlugins: [
rehypeSlug,
[rehypePrettyCode, prettyCodeOptions],
],
},
},
})
return content
}The Plugin Pipeline
When compileMDX() runs, your MDX goes through a processing pipeline:
| Plugin | Stage | What it does |
|---|---|---|
remark-gfm | Remark (Markdown AST) | Enables GFM: tables, task lists, strikethrough, autolinks |
rehype-slug | Rehype (HTML AST) | Adds id="my-heading" to every <h2>, <h3>, etc. for anchor links |
rehype-pretty-code | Rehype (HTML AST) | Transforms code blocks into Shiki-highlighted HTML with theme colors |
Why next-mdx-remote/rsc?
The /rsc import is critical — it means MDX compilation happens on the server as a React Server Component. Benefits:
- No client-side JavaScript for the compiled content
- Faster page loads — the MDX is already rendered HTML when it reaches the browser
- Direct file system access — server components can read files, query databases, etc.
Build the Blog Listing Page
Create app/blog/page.tsx — this page lists all posts:
// app/blog/page.tsx
import Link from 'next/link'
import { getAllPosts } from '@/lib/posts'
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>
{posts.length === 0 ? (
<p className="text-muted-foreground">No posts yet. Check back soon!</p>
) : (
<div className="space-y-8">
{posts.map((post) => (
<article key={post.slug} className="group">
<Link href={`/blog/${post.slug}`}>
<div className="border rounded-lg p-6 transition-colors hover:bg-accent">
<time className="text-sm text-muted-foreground">
{post.date}
</time>
<h2 className="text-2xl font-semibold mt-1 mb-2 group-hover:text-primary">
{post.title}
</h2>
<p className="text-muted-foreground">{post.description}</p>
{post.tags.length > 0 && (
<div className="flex gap-2 mt-3">
{post.tags.map((tag) => (
<span
key={tag}
className="px-2 py-1 text-xs rounded-full bg-secondary text-secondary-foreground"
>
{tag}
</span>
))}
</div>
)}
</div>
</Link>
</article>
))}
</div>
)}
</div>
)
}Visit http://localhost:3000/blog — you should see your "Hello World" post listed.
Build the Post Detail Page
Create app/blog/[slug]/page.tsx — this is the dynamic route that renders a single post:
// app/blog/[slug]/page.tsx
import { Metadata } from 'next'
import { notFound } from 'next/navigation'
import Link from 'next/link'
import { getPostBySlug } from '@/lib/posts'
import { compileMDXContent } from '@/lib/mdx'
interface PageProps {
params: Promise<{ slug: string }>
}
// Force on-demand rendering (no static generation)
export const dynamic = 'force-dynamic'
export async function generateMetadata({ params }: PageProps): Promise<Metadata> {
const { slug } = await params
const post = getPostBySlug(slug)
if (!post) {
return { title: 'Post Not Found' }
}
return {
title: post.title,
description: post.description,
openGraph: {
title: post.title,
description: post.description,
type: 'article',
publishedTime: post.date,
},
}
}
export default async function PostPage({ params }: PageProps) {
const { slug } = await params
const post = getPostBySlug(slug)
if (!post) {
notFound()
}
// Compile MDX on every request (on-demand rendering)
const content = await compileMDXContent(post.content)
return (
<article className="max-w-3xl mx-auto px-4 py-12">
<header className="mb-8">
<Link
href="/blog"
className="inline-flex items-center text-muted-foreground hover:text-foreground mb-6"
>
← Back to blog
</Link>
<time className="block text-sm text-muted-foreground mb-2">
{post.date}
</time>
<h1 className="text-4xl font-bold mb-4">{post.title}</h1>
{post.tags.length > 0 && (
<div className="flex flex-wrap gap-2">
{post.tags.map((tag) => (
<span
key={tag}
className="px-2 py-1 text-sm rounded-full bg-secondary text-secondary-foreground"
>
{tag}
</span>
))}
</div>
)}
</header>
{/* Prose class for beautiful typography */}
<div className="prose prose-slate dark:prose-invert max-w-none">
{content}
</div>
</article>
)
}Key Points
export const dynamic = 'force-dynamic'— tells Next.js to compile MDX on every request (not at build time)params: Promise<{ slug: string }>— Next.js 16 passes params as a PromisecompileMDXContent(post.content)— compiles the raw MDX string into a React elementproseclass — Tailwind Typography plugin styles all the rendered HTML beautifully
Tailwind Typography Plugin
The prose class comes from @tailwindcss/typography. Install it:
npm install @tailwindcss/typographyThen import it in your app/globals.css:
@import "tailwindcss";
@import "@tailwindcss/typography";Tailwind v4 note: In Tailwind v4, plugins are imported as CSS — no
tailwind.config.tsneeded. The@tailwindcss/typographypackage registers theproseclasses automatically when imported.
Now all your rendered MDX gets beautiful default typography — headings, paragraphs, code blocks, lists, blockquotes, and tables are all styled out of the box.
Dark Mode for Prose
The dark:prose-invert class flips typography colors in dark mode. Add it alongside prose:
<div className="prose prose-slate dark:prose-invert max-w-none">
{content}
</div>Understanding the Data Flow
Let's trace exactly what happens when a user visits /blog/hello-world:
Step by step:
- Route match — Next.js maps
/blog/hello-worldtoapp/blog/[slug]/page.tsxwithslug = "hello-world" - Read file —
getPostBySlug('hello-world')readscontent/posts/hello-world.mdx - Parse frontmatter —
gray-matterextracts{ title, description, date, tags }and returns the raw MDX body - Compile MDX —
compileMDXContent()runs the remark/rehype pipeline, producing a React element - Render — The React element is rendered on the server (RSC), producing HTML
- Response — The fully-rendered HTML is sent to the browser — no client-side JavaScript needed
Syntax Highlighting with Shiki
rehype-pretty-code uses Shiki under the hood — the same highlighter used by VS Code. This means:
- Accurate highlighting — uses real TextMate grammars, not regex approximations
- Theme support — any VS Code theme works (
github-dark,one-dark-pro,dracula, etc.) - Language coverage — 200+ languages supported out of the box
How Code Blocks are Highlighted
When you write a fenced code block in MDX:
```typescript
const x: number = 42
```The pipeline transforms it:
- Markdown parser sees
```typescript→ creates a code block AST node withlang: "typescript" - rehype-pretty-code intercepts the code block → sends the code to Shiki
- Shiki tokenizes
const x: number = 42using TypeScript grammar → appliesgithub-darktheme colors - Output — a
<pre><code>element with inline color styles for each token
The result: every keyword, string, number, and type annotation gets the correct color, just like in your editor.
Theme-Aware Highlighting
To make code blocks match your site's dark/light mode, you can configure dual themes:
// lib/mdx.ts — alternative: dual theme support
const prettyCodeOptions = {
theme: {
dark: 'github-dark',
light: 'github-light',
},
keepBackground: true,
defaultLang: 'plaintext',
}This generates code blocks with CSS variables that respond to your dark: class. We'll keep github-dark for simplicity since most developer blogs use dark code blocks regardless of the site theme.
Custom MDX Components
One of MDX's superpowers is embedding React components in your content. You pass a components map to compileMDX():
// lib/mdx.ts — with custom components
import { compileMDX } from 'next-mdx-remote/rsc'
// A custom callout component
function Callout({ type = 'info', children }: { type?: string; children: React.ReactNode }) {
const styles = {
info: 'bg-blue-50 dark:bg-blue-950 border-blue-200 dark:border-blue-800',
warning: 'bg-yellow-50 dark:bg-yellow-950 border-yellow-200 dark:border-yellow-800',
danger: 'bg-red-50 dark:bg-red-950 border-red-200 dark:border-red-800',
}
return (
<div className={`border rounded-lg p-4 my-4 ${styles[type as keyof typeof styles] || styles.info}`}>
{children}
</div>
)
}
export async function compileMDXContent(source: string) {
const { content } = await compileMDX({
source,
options: {
parseFrontmatter: true,
mdxOptions: {
remarkPlugins: [remarkGfm],
rehypePlugins: [
rehypeSlug,
[rehypePrettyCode, prettyCodeOptions],
],
},
},
components: {
Callout, // Now usable in any MDX file as <Callout>
},
})
return content
}Then in any MDX file:
<Callout type="warning">
Make sure to install all dependencies before continuing!
</Callout>You can add as many custom components as you like — tabs, accordions, image galleries, embedded code sandboxes, etc.
GitHub Flavored Markdown in Action
The remark-gfm plugin enables several Markdown extensions. Here's what each one looks like:
Tables
| Feature | Supported |
|---------|-----------|
| Tables | ✅ |
| Task lists | ✅ |
| Strikethrough | ✅ |Task Lists
- [x] Install dependencies
- [x] Create content directory
- [ ] Write first post
- [ ] Deploy to productionStrikethrough
~~This text is struck through~~Autolinks
Visit https://nextjs.org for documentation.All of these render correctly because remark-gfm transforms them into proper HTML before rehype processes them.
Heading Anchor Links
The rehype-slug plugin adds id attributes to every heading:
<!-- Input: ## My Heading -->
<!-- Output: <h2 id="my-heading">My Heading</h2> -->This enables:
- Direct linking — share
https://yourblog.com/blog/my-post#my-heading - Table of contents — build a ToC component by extracting heading IDs
- Scroll to section — users can click a heading to get its permalink
Common Issues and Fixes
1. "Module not found: gray-matter"
Make sure you installed all dependencies:
npm install next-mdx-remote gray-matter remark-gfm rehype-pretty-code rehype-slug shiki2. Code blocks not highlighted
Check that your code blocks have a language tag:
```typescript ← Language must be specified
const x = 42
```Without a language tag, rehype-pretty-code uses the defaultLang fallback (which we set to plaintext).
3. Frontmatter showing as raw text
If you see --- and YAML in the rendered output, make sure:
parseFrontmatter: trueis set in thecompileMDX()options- You're passing
post.content(without frontmatter), not the raw file string
gray-matter already strips the frontmatter from the content. Don't parse it twice.
4. "prose" styles not applied
Install the typography plugin and import it in your CSS:
npm install @tailwindcss/typography/* app/globals.css */
@import "tailwindcss";
@import "@tailwindcss/typography";5. Hydration mismatch errors
If you see "Text content does not match" errors, make sure:
- Your MDX doesn't contain browser-specific code (e.g.,
window,document) - All components used in MDX are either Server Components or properly wrapped with
'use client' export const dynamic = 'force-dynamic'is set on the page
Full File Reference
Here's every file we created or modified in this phase:
| File | Purpose |
|---|---|
content/posts/hello-world.mdx | First test post with frontmatter |
lib/posts.ts | getAllPosts() and getPostBySlug() — read MDX from disk |
lib/mdx.ts | compileMDXContent() — MDX compiler with remark/rehype plugins |
app/blog/page.tsx | Blog listing page — shows all posts |
app/blog/[slug]/page.tsx | Post detail page — on-demand MDX rendering |
app/globals.css | Added @tailwindcss/typography import |
Summary
In this phase you:
✅ Learned what MDX is and why it's perfect for a developer blog
✅ Chose on-demand rendering over static generation for instant content updates
✅ Installed next-mdx-remote, gray-matter, remark-gfm, rehype-pretty-code, and rehype-slug
✅ Created the content/posts/ directory and wrote your first MDX post
✅ Defined TypeScript types for PostMeta and Post
✅ Built getAllPosts() and getPostBySlug() to read posts from disk
✅ Configured the MDX compiler with remark/rehype plugins and Shiki syntax highlighting
✅ Built the blog listing page (/blog) and post detail page (/blog/[slug])
✅ Added Tailwind Typography for beautiful prose styling
Next up — Phase 3: PostgreSQL + Drizzle ORM (Coming Soon) You'll add a database for view counts, newsletter subscribers, and comments — connecting Drizzle ORM to a PostgreSQL instance running in Docker.
Series Index
| Post | Title | Status |
|---|---|---|
| BLOG-1 | Build a Personal Blog — Roadmap | ✅ Complete |
| BLOG-2 | Phase 1: Project Setup — Next.js 16 + ShadCN/UI | ✅ Complete |
| BLOG-3 | Phase 2: MDX On-Demand Rendering | ✅ You are here |
| BLOG-4 | Phase 3: PostgreSQL + Drizzle ORM | Coming Soon |
| BLOG-5 | Phase 4: Tags, Search & Pagination | Coming Soon |
| BLOG-6 | Phase 5: Docker Compose | Coming Soon |
| BLOG-7 | Phase 6: Deploy to Ubuntu VPS on Hostinger | Coming Soon |
| BLOG-8 | Phase 7: Custom Domain Setup on Hostinger | Coming Soon |
📬 Subscribe to Newsletter
Get the latest blog posts delivered to your inbox every week. No spam, unsubscribe anytime.
We respect your privacy. Unsubscribe at any time.
💬 Comments
Sign in to leave a comment
We'll never post without your permission.