Back to Blog
Mar 11, 20264 min readOnuzulike Anthony

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.

EngineeringMDXNext.jsContentEngineering

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:

mdx
---
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

typescript
// 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):

typescript
// 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.

typescript
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:

css
[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:

typescript
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:

typescript
// 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:

typescript
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.