Building an MDX Content Pipeline in Next.js 15
Gray-matter frontmatter parsing, next-mdx-remote/rsc for App Router, rehype-pretty-code syntax highlighting, and TOC generation with rehype-slug.
MDX lets you write content in Markdown and embed React components. Combined with Next.js App Router's force-static export, you get a content site that renders to pure HTML at build time — zero JavaScript for content pages, instant loads, and full markdown expressiveness.
File Structure
src/content/
blog/
building-a-rag-system.mdx
cloudflare-workers-basics.mdx
docs/
cognix/
overview.mdx
architecture.mdx
Each file has frontmatter at the top:
---
title: "Building a RAG System"
description: "Step-by-step RAG pipeline with ChromaDB and hybrid retrieval."
date: 2026-01-15
author: Onuzulike Anthony
category: AI/ML
tags:
- RAG
- Embeddings
---
Content starts here...Reading Posts
// src/lib/blog.ts
import fs from "fs";
import path from "path";
import matter from "gray-matter";
const CONTENT_DIR = path.join(process.cwd(), "src/content/blog");
export interface BlogPost {
slug: string;
title: string;
description: string;
date: string;
author: string;
category: string;
tags: string[];
content: string;
}
export function getAllPosts(): BlogPost[] {
const files = fs.readdirSync(CONTENT_DIR).filter(f => f.endsWith(".mdx"));
return files
.map(file => {
const slug = file.replace(/\.mdx$/, "");
const raw = fs.readFileSync(path.join(CONTENT_DIR, file), "utf-8");
const { data, content } = matter(raw);
return {
slug,
title: data.title,
description: data.description,
date: data.date instanceof Date ? data.date.toISOString().slice(0, 10) : String(data.date),
author: data.author ?? "Onuzulike Anthony",
category: data.category ?? "Engineering",
tags: data.tags ?? [],
content,
};
})
.sort((a, b) => new Date(b.date).getTime() - new Date(a.date).getTime());
}
export function getPost(slug: string): BlogPost | null {
const filePath = path.join(CONTENT_DIR, `${slug}.mdx`);
if (!fs.existsSync(filePath)) return null;
const raw = fs.readFileSync(filePath, "utf-8");
const { data, content } = matter(raw);
return {
slug,
title: data.title,
description: data.description,
date: data.date instanceof Date ? data.date.toISOString().slice(0, 10) : String(data.date),
author: data.author ?? "Onuzulike Anthony",
category: data.category ?? "Engineering",
tags: data.tags ?? [],
content,
};
}Rendering MDX in App Router
next-mdx-remote/rsc compiles MDX at request time (or build time with force-static):
// src/components/mdx-content.tsx
import { MDXRemote } from "next-mdx-remote/rsc";
import rehypePrettyCode from "rehype-pretty-code";
import rehypeSlug from "rehype-slug";
import rehypeAutolinkHeadings from "rehype-autolink-headings";
const prettyCodeOptions = {
theme: "github-dark",
keepBackground: true,
};
const rehypePlugins = [
[rehypePrettyCode, prettyCodeOptions],
rehypeSlug,
[rehypeAutolinkHeadings, { behavior: "wrap" }],
];
interface MdxContentProps {
source: string;
components?: Record<string, React.ComponentType>;
}
export function MdxContent({ source, components }: MdxContentProps) {
return (
<MDXRemote
source={source}
options={{ mdxOptions: { rehypePlugins } }}
components={components}
/>
);
}Syntax Highlighting
rehype-pretty-code uses Shiki under the hood and produces zero-runtime syntax highlighting — the HTML already contains the color spans, no JavaScript needed.
import rehypePrettyCode, { type Options } from "rehype-pretty-code";
const options: Options = {
theme: "github-dark",
keepBackground: true,
onVisitLine(node) {
// Add class to empty lines to prevent collapse
if (node.children.length === 0) {
node.children = [{ type: "text", value: " " }];
}
},
onVisitHighlightedLine(node) {
node.properties.className?.push("highlighted");
},
};In your CSS, style highlighted lines:
[data-highlighted-line] {
background: rgba(255, 255, 255, 0.08);
border-left: 2px solid rgba(255, 255, 255, 0.4);
padding-left: 1rem;
margin-left: -1rem;
}TOC Generation
rehype-slug adds id attributes to headings. rehype-autolink-headings wraps them in anchor links. To generate a sidebar TOC, extract headings from the raw markdown:
export function extractToc(content: string): { id: string; text: string; level: number }[] {
const headingRegex = /^(#{1,6})\s+(.+)$/gm;
const toc = [];
for (const match of content.matchAll(headingRegex)) {
const level = match[1].length;
const text = match[2].replace(/`[^`]+`/g, m => m.slice(1, -1)); // strip inline code backticks
const id = text
.toLowerCase()
.replace(/[^a-z0-9\s-]/g, "")
.replace(/\s+/g, "-");
toc.push({ id, text, level });
}
return toc;
}Static Generation
In a page component:
// app/blog/[slug]/page.tsx
export const dynamic = "force-static";
export async function generateStaticParams() {
return getAllPosts().map(post => ({ slug: post.slug }));
}
export async function generateMetadata({ params }: { params: { slug: string } }) {
const post = getPost(params.slug);
return { title: post?.title, description: post?.description };
}
export default function BlogPostPage({ params }: { params: { slug: string } }) {
const post = getPost(params.slug);
if (!post) notFound();
return (
<article>
<h1>{post.title}</h1>
<MdxContent source={post.content} />
</article>
);
}With force-static, Next.js renders every [slug] at build time. The result is a static HTML file per post — served instantly from Cloudflare's edge with no server-side rendering overhead.
Custom MDX Components
Swap default HTML elements with custom React components:
const components = {
pre: CodeBlock, // Custom code block with copy button
a: ExternalLink, // Opens external links in new tab
img: OptimizedImage, // next/image wrapper
Callout, // Custom callout box (warning, info, tip)
};
<MdxContent source={post.content} components={components} />This lets content authors use <Callout type="warning"> directly in MDX files while keeping the rendering logic in React.