nextjsmdxtypescriptreact

Building an MDX Article System in Next.js: Part 1

3···6 min read·
J
Jagadhiswaran Devaraj

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.

J

Jagadhiswaran Devaraj

Full-Stack Developer

📢 Connect & Code

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! 💡🔥

Comments

💡Insight
Question
🔥Appreciate
💬General