Building an MDX Article System in Next.js: Part 1
Building an MDX Article System in Next.js: Part 1
When I added a blog to my portfolio, I wanted complete control over my content while keeping the writing experience pleasant. After exploring options, I built a custom MDX system with Next.js App Router.
In this two-part series, I'll walk through how I built it. Part 1 covers the foundation and core implementation.
Why I Chose MDX
If you're not familiar with MDX, it's essentially Markdown with JSX superpowers. This combination gives me:
- The simplicity of writing in Markdown
- The ability to embed custom React components when needed
- Version control through Git
- No dependence on external CMS platforms
MDX bridges the gap between content and code in a way that feels natural for developers. I can write primarily in Markdown but drop in interactive components whenever I need something more powerful.
My File Structure
Here's how I've organized the project:
src/
├── app/
│ ├── articles/ // App Router routes
│ │ ├── page.tsx // Article listing
│ │ └── [slug]/ // Dynamic routes
│ │ ├── not-found.tsx
│ │ └── page.tsx // Individual article
├── components/
│ ├── articles/ // Article components
│ │ ├── article-card.tsx // Card preview
│ │ ├── article-comments.tsx // Comments section
│ │ ├── article-content.tsx // Main display
│ │ ├── article-list.tsx // Article listing
│ │ ├── article-search.tsx // Search
│ │ ├── article-share-card.tsx // Social sharing
│ │ ├── article-tags.tsx // Tag display
│ │ ├── article-view-counter.tsx // View tracking
│ │ ├── articles-likes-counter.tsx // Likes tracking
│ │ └── table-of-contents.tsx // TOC navigation
│ └── mdx/ // MDX components
│ ├── callout.tsx // Custom callouts
│ ├── code-block.tsx // Code blocks
│ ├── heading.tsx // Headings
│ ├── image.tsx // Images
│ ├── index.tsx // Component exports
│ ├── link.tsx // Links
│ └── paragraph.tsx // Paragraphs
├── content/
│ └── articles/ // MDX content files
└── lib/
├── mdx.ts // MDX processing
└── types/ // Type definitions
I've organized everything into logical sections following Next.js App Router conventions. This separation keeps the codebase clean and maintainable.
How The System Works
The magic happens through a pipeline that processes MDX files and renders them as beautiful, interactive articles.
1. Content Creation
I write articles in my code editor as .mdx
files stored in the content/articles
directory. Each file starts with frontmatter containing metadata:
---
title: "Building an MDX Article System"
date: "2025-04-19"
description: "How I built a custom article system..."
tags: ["nextjs", "mdx", "typescript"]
---
Content goes here...
2. MDX Processing
The lib/mdx.ts
file handles processing the MDX content:
// Simplified version of lib/mdx.ts
import fs from 'fs/promises';
import path from 'path';
import matter from 'gray-matter';
import { compileMDX } from 'next-mdx-remote/rsc';
import { MDXComponents } from '@/components/mdx';
export async function getArticleBySlug(slug) {
// Find the file matching the slug
const filePath = /* logic to find file by slug */;
// Read the file contents
const source = await fs.readFile(filePath, 'utf8');
// Extract frontmatter and content
const { content, data } = matter(source);
// Compile MDX with our custom components
const mdxSource = await compileMDX({
source: content,
components: MDXComponents,
});
return {
content: mdxSource,
frontmatter: data,
slug,
};
}
This process:
- Reads the MDX file from disk
- Extracts the frontmatter metadata
- Compiles the MDX content into React components
- Returns everything in a structured format
3. Rendering Articles
In the app/articles/[slug]/page.tsx
file, I fetch and render the article:
// Simplified version of app/articles/[slug]/page.tsx
export default async function ArticlePage({ params }) {
const { slug } = params;
const article = await getArticleBySlug(slug);
if (!article) {
notFound();
}
return (
<div className="article-container">
<h1>{article.frontmatter.title}</h1>
<ArticleTags tags={article.frontmatter.tags} />
<ArticleContent content={article.content} />
<ArticleComments slug={article.slug} />
</div>
);
}
The beauty of Next.js App Router is that pages are React Server Components by default, so I can directly use async functions to fetch data during rendering.
Custom MDX Components
One of the most powerful aspects of this system is the ability to customize how MDX elements render.
The Callout Component
For important notes and warnings, I use a <Callout>
component:
// components/mdx/callout.tsx
import { ReactNode } from 'react';
type CalloutType = 'info' | 'warning' | 'tip';
interface CalloutProps {
children: ReactNode;
type?: CalloutType;
}
export function Callout({ children, type = 'info' }: CalloutProps) {
const styles = {
info: 'bg-blue-50 border-blue-200 dark:bg-blue-900/30 dark:border-blue-800',
warning: 'bg-amber-50 border-amber-200 dark:bg-amber-900/30 dark:border-amber-800',
tip: 'bg-green-50 border-green-200 dark:bg-green-900/30 dark:border-green-800',
};
return (
<div className={`p-4 border-l-4 rounded my-6 ${styles[type]}`}>
{children}
</div>
);
}
I can use it directly in MDX:
<Callout type="warning">
Always back up your data before running migrations.
</Callout>
Custom Heading Component
To enable table of contents navigation, I created a custom heading component:
// components/mdx/heading.tsx
import { createElement, ReactNode } from 'react';
interface HeadingProps {
children: ReactNode;
level: 1 | 2 | 3 | 4 | 5 | 6;
}
export function Heading({ children, level }: HeadingProps) {
// Create an ID from the heading text
const id = typeof children === 'string'
? children.toLowerCase().replace(/\s+/g, '-').replace(/[^\w-]/g, '')
: '';
return createElement(
`h${level}`,
{
id,
className: `heading-${level}`
},
children
);
}
This automatically generates IDs for headings based on the text content.
Enhanced Code Blocks
For code snippets, I've created a custom component with syntax highlighting and a copy button:
// Simplified version of components/mdx/code-block.tsx
export function CodeBlock({ children, className }) {
// Extract language from className (e.g., "language-javascript")
const language = className?.replace('language-', '') || 'text';
return (
<div className="relative group">
<button
className="absolute right-2 top-2 opacity-0 group-hover:opacity-100"
onClick={() => /* Copy to clipboard logic */}
>
Copy
</button>
<pre className={className}>{children}</pre>
</div>
);
}
This creates a better code sharing experience with proper syntax highlighting and easy copying.
In Part 2...
In Part 2, I'll cover:
- The Table of Contents implementation
- Article metrics tracking
- The comment system
- Challenges I faced during development
- My plans for adding an in-browser editor
If you've found this useful so far, you'll definitely want to check out the second part where I dive into the more advanced features of this system.
Follow me for hardcore technical insights on JavaScript, Full-Stack Development, AI, and Scaling Systems. Let's geek out over code, architecture, and all things tech! 💡🔥