Back to blog

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

nextjsmdxreacttypescriptblog
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.mdx files work with any MDX-compatible tool or framework

On-Demand Rendering vs Static Generation

Next.js gives you two main strategies for rendering content:

FeatureStatic Generation (SSG)On-Demand (Server)
When compiledAt build timeAt request time
Content updatesRequires rebuildInstant — edit file, refresh
Build timeGrows with post countConstant
Best forMarketing sites, docsBlogs with frequent edits
Next.js APIgenerateStaticParams()export const dynamic = 'force-dynamic'

We'll use on-demand rendering because:

  1. Zero rebuild — edit an MDX file, refresh the browser, see the change
  2. Simpler deploy — no need to rebuild the entire site when you publish a new post
  3. Perfect for self-hosted — your VPS has plenty of power to compile MDX per request
  4. 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
PackagePurpose
next-mdx-remoteCompile and render MDX in React Server Components
gray-matterParse YAML frontmatter (title, date, tags) from MDX files
remark-gfmGitHub Flavored Markdown — tables, task lists, strikethrough
rehype-pretty-codeSyntax highlighting for code blocks (powered by Shiki)
rehype-slugAuto-generate id attributes on headings for anchor links
shikiThe syntax highlighting engine behind rehype-pretty-code

Content Directory Structure

Create the directory where all your MDX posts will live:

mkdir -p content/posts

Your 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.json

Note: In a later phase we'll add bilingual support with content/posts/en/ and content/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:

  1. Read directoryfs.readdirSync() lists all .mdx files
  2. Parse each filegray-matter() separates frontmatter from content
  3. Build metadata — extract title, description, date, tags from frontmatter
  4. 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:

PluginStageWhat it does
remark-gfmRemark (Markdown AST)Enables GFM: tables, task lists, strikethrough, autolinks
rehype-slugRehype (HTML AST)Adds id="my-heading" to every <h2>, <h3>, etc. for anchor links
rehype-pretty-codeRehype (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

  1. export const dynamic = 'force-dynamic' — tells Next.js to compile MDX on every request (not at build time)
  2. params: Promise<{ slug: string }> — Next.js 16 passes params as a Promise
  3. compileMDXContent(post.content) — compiles the raw MDX string into a React element
  4. prose class — Tailwind Typography plugin styles all the rendered HTML beautifully

Tailwind Typography Plugin

The prose class comes from @tailwindcss/typography. Install it:

npm install @tailwindcss/typography

Then 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.ts needed. The @tailwindcss/typography package registers the prose classes 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:

  1. Route match — Next.js maps /blog/hello-world to app/blog/[slug]/page.tsx with slug = "hello-world"
  2. Read filegetPostBySlug('hello-world') reads content/posts/hello-world.mdx
  3. Parse frontmattergray-matter extracts { title, description, date, tags } and returns the raw MDX body
  4. Compile MDXcompileMDXContent() runs the remark/rehype pipeline, producing a React element
  5. Render — The React element is rendered on the server (RSC), producing HTML
  6. 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:

  1. Markdown parser sees ```typescript → creates a code block AST node with lang: "typescript"
  2. rehype-pretty-code intercepts the code block → sends the code to Shiki
  3. Shiki tokenizes const x: number = 42 using TypeScript grammar → applies github-dark theme colors
  4. 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 production

Strikethrough

~~This text is struck through~~
Visit https://nextjs.org for documentation.

All of these render correctly because remark-gfm transforms them into proper HTML before rehype processes them.


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 shiki

2. 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: true is set in the compileMDX() 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:

FilePurpose
content/posts/hello-world.mdxFirst test post with frontmatter
lib/posts.tsgetAllPosts() and getPostBySlug() — read MDX from disk
lib/mdx.tscompileMDXContent() — MDX compiler with remark/rehype plugins
app/blog/page.tsxBlog listing page — shows all posts
app/blog/[slug]/page.tsxPost detail page — on-demand MDX rendering
app/globals.cssAdded @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

PostTitleStatus
BLOG-1Build a Personal Blog — Roadmap✅ Complete
BLOG-2Phase 1: Project Setup — Next.js 16 + ShadCN/UI✅ Complete
BLOG-3Phase 2: MDX On-Demand Rendering✅ You are here
BLOG-4Phase 3: PostgreSQL + Drizzle ORMComing Soon
BLOG-5Phase 4: Tags, Search & PaginationComing Soon
BLOG-6Phase 5: Docker ComposeComing Soon
BLOG-7Phase 6: Deploy to Ubuntu VPS on HostingerComing Soon
BLOG-8Phase 7: Custom Domain Setup on HostingerComing 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.