Table of Contents
Building a Powerful Headless CMS with Next.js and MDX
Learn how to create a flexible content management system that separates your content from presentation using Next.js and MDX, giving you full control over your content while maintaining developer experience.
🌐 Introduction to Headless CMS
In today's digital landscape, content management systems (CMS) have evolved beyond traditional monolithic platforms like WordPress. The rise of headless CMS architecture represents a significant shift in how we approach content management. Unlike traditional CMS platforms that handle both content management and presentation, a headless CMS focuses exclusively on the backend—storing and delivering content through APIs without dictating how that content should be displayed.
This separation of concerns offers developers unprecedented flexibility, allowing them to build frontends using their preferred technologies while still providing content editors with user-friendly interfaces for content creation.
Next.js, with its powerful features for building React applications, pairs exceptionally well with this headless approach. When combined with MDX (Markdown with JSX), it creates a powerful stack for managing and displaying content.
In this comprehensive guide, we'll explore how to build your own headless CMS using Next.js and MDX, giving you complete control over your content while maintaining an excellent developer experience.
🤔 Why Build a Custom Headless CMS?
Before diving into implementation, let's consider why you might want to build your own headless CMS rather than using an existing solution:
- Complete control: You own your content and the entire system
- No vendor lock-in: Avoid being tied to a specific CMS provider
- Custom workflows: Build exactly what your team needs
- Performance optimization: Tailor the system to your specific performance requirements
- Cost-effective: Avoid subscription fees for commercial headless CMS platforms
- Learning opportunity: Gain deeper understanding of modern web architecture
While there are excellent commercial options like Storyblok, Builder.io, and open-source alternatives like Strapi (which many consider the leading open-source headless CMS), building your own system with MDX offers unique advantages for certain projects, particularly content-heavy sites like blogs, documentation, and marketing sites.
![Image description: A diagram showing the architecture of a headless CMS with content creation separated from content delivery]
📚 Understanding MDX: The Perfect Content Format
MDX stands for Markdown with JSX. It combines the simplicity of Markdown with the power of React components, making it an ideal format for content that requires both rich text and interactive elements.
Why MDX Works Well for a Headless CMS
MDX offers several advantages that make it perfect for our custom headless CMS:
- Developer-friendly: Content is stored as plain text files, making version control easy
- Component-based: Embed React components directly in your content
- Extensible: Create custom components for specific content needs
- Portable: Content can be easily moved between different systems
- Performant: Content can be statically generated at build time
Here's a simple example of MDX content:
# My First Blog Post
This is a paragraph with **bold text** and *italic text*.
<CustomCallout type="info">
This is a custom React component embedded in Markdown!
</CustomCallout>
## Code Example
```js
function hello() {
console.log("Hello, world!");
}
With MDX, your content becomes much more than just text—it becomes a fully interactive part of your application.
🛠 Setting Up Your Next.js Project
Let's start by creating a new Next.js project. Make sure you have Node.js installed (version 14 or higher recommended).
npx create-next-app@latest my-headless-cms
cd my-headless-cms
When prompted, select the following options:
- Use TypeScript: Yes
- Use ESLint: Yes
- Use Tailwind CSS: Yes (optional, but recommended for styling)
- Use the
src/
directory: Yes - Use the App Router: Yes
Next, let's install the necessary dependencies for working with MDX:
npm install next-mdx-remote gray-matter rehype-highlight rehype-slug remark-gfm
These packages will help us:
next-mdx-remote
: Compile and render MDX contentgray-matter
: Parse front matter from MDX filesrehype-highlight
: Add syntax highlighting to code blocksrehype-slug
: Add IDs to headings for anchor linksremark-gfm
: Support GitHub Flavored Markdown features
📁 Creating the Content Structure
For our headless CMS, we'll create a structured content directory that will house all our MDX files. This approach allows us to organize content logically while maintaining a clean separation from the presentation layer.
First, let's create a content
directory at the root of our project:
mkdir -p content/{blog,pages}
This creates a content directory with subdirectories for blog posts and static pages. Now, let's create a sample blog post:
touch content/blog/getting-started.mdx
Open this file and add some content with front matter:
---
title: Getting Started with Our Headless CMS
description: Learn how to create and manage content using our custom headless CMS built with Next.js and MDX.
date: 2024-07-15
author: Your Name
tags: [nextjs, mdx, cms]
---
# Getting Started with Our Headless CMS
Welcome to our custom headless CMS! This system allows you to create rich content using Markdown and React components.
## What is a Headless CMS?
A headless CMS deals only with the content or the backend without a frontend, hence the name. It is used for editing and storing content but doesn't specify how the content is visually presented to the users.
<InfoBox>
This is a custom React component that can be used directly in your MDX content!
</InfoBox>
The front matter (the section between the ---
markers) contains metadata about the content that we'll use for filtering, sorting, and displaying information about the post.
⚙️ Building the MDX Processing Pipeline
Now that we have our content structure set up, we need to create utilities to process and render our MDX files. Let's create a lib
directory to house these utilities:
mkdir -p src/lib
touch src/lib/mdx.ts
Open src/lib/mdx.ts
and add the following code:
import fs from 'fs';
import path from 'path';
import matter from 'gray-matter';
import { serialize } from 'next-mdx-remote/serialize';
import rehypeHighlight from 'rehype-highlight';
import rehypeSlug from 'rehype-slug';
import remarkGfm from 'remark-gfm';
// Define the content types we support
const contentTypes = ['blog', 'pages'];
// Define the content directory
const contentDirectory = path.join(process.cwd(), 'content');
// Get all files for a specific content type
export async function getAllFiles(type: string) {
if (!contentTypes.includes(type)) {
throw new Error(`Invalid content type: ${type}`);
}
const typeDirectory = path.join(contentDirectory, type);
const files = fs.readdirSync(typeDirectory);
return files.filter(file => file.endsWith('.mdx'));
}
// Get content for a specific file
export async function getFileContent(type: string, filename: string) {
const filePath = path.join(contentDirectory, type, filename);
const fileContent = fs.readFileSync(filePath, 'utf8');
// Parse the front matter
const { data, content } = matter(fileContent);
// Serialize the MDX content
const mdxSource = await serialize(content, {
mdxOptions: {
rehypePlugins: [rehypeHighlight, rehypeSlug],
remarkPlugins: [remarkGfm],
},
scope: data,
});
return {
metadata: data,
content: mdxSource,
slug: filename.replace('.mdx', ''),
};
}
// Get all content for a specific type
export async function getAllContent(type: string) {
const files = await getAllFiles(type);
const content = await Promise.all(
files.map(async (file) => {
const { metadata, slug } = await getFileContent(type, file);
return { ...metadata, slug };
})
);
// Sort by date if it exists
return content.sort((a, b) => {
if (a.date && b.date) {
return new Date(b.date).getTime() - new Date(a.date).getTime();
}
return 0;
});
}
This utility provides three main functions:
getAllFiles
: Get all MDX files for a specific content typegetFileContent
: Get the parsed content and metadata for a specific filegetAllContent
: Get metadata for all content of a specific type
🧩 Creating Custom MDX Components
One of the powerful features of MDX is the ability to use custom React components within your content. Let's create some components that will enhance our content:
mkdir -p src/components/mdx
touch src/components/mdx/index.tsx
Open src/components/mdx/index.tsx
and add the following:
import React from 'react';
import Image from 'next/image';
import Link from 'next/link';
// Info box component
export const InfoBox = ({ children }: { children: React.ReactNode }) => {
return (
<div className="bg-blue-50 border-l-4 border-blue-500 p-4 my-4 rounded">
<div className="flex">
<div className="flex-shrink-0">
<svg className="h-5 w-5 text-blue-500" viewBox="0 0 20 20" fill="currentColor">
<path fillRule="evenodd" d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a1 1 0 000 2v3a1 1 0 001 1h1a1 1 0 100-2v-3a1 1 0 00-1-1H9z" clipRule="evenodd" />
</svg>
</div>
<div className="ml-3">
<p className="text-sm text-blue-700">{children}</p>
</div>
</div>
</div>
);
};
// Custom link component
export const CustomLink = (props: any) => {
const href = props.href;
const isInternalLink = href && (href.startsWith('/') || href.startsWith('#'));
if (isInternalLink) {
return (
<Link href={href} {...props}>
{props.children}
</Link>
);
}
return <a target="_blank" rel="noopener noreferrer" {...props} />;
};
// Enhanced image component
export const CustomImage = (props: any) => {
return (
<div className="my-6">
<Image
{...props}
alt={props.alt || 'Image'}
width={props.width || 800}
height={props.height || 450}
className="rounded-lg mx-auto"
/>
{props.caption && (
<p className="text-center text-sm text-gray-500 mt-2">{props.caption}</p>
)}
</div>
);
};
// MDX components mapping
export const mdxComponents = {
a: CustomLink,
img: CustomImage,
Image: CustomImage,
InfoBox,
};
These components will enhance our MDX content with custom styling and functionality.
🔄 Creating the Content API Routes
Now, let's create API routes to access our content programmatically. This is a key aspect of a headless CMS—providing content through APIs.
mkdir -p src/app/api/content/[type]/route.ts
touch src/app/api/content/[type]/route.ts
Open src/app/api/content/[type]/route.ts
and add:
import { NextRequest, NextResponse } from 'next/server';
import { getAllContent } from '@/lib/mdx';
export async function GET(
request: NextRequest,
{ params }: { params: { type: string } }
) {
try {
const { type } = params;
const content = await getAllContent(type);
return NextResponse.json(content);
} catch (error) {
return NextResponse.json(
{ error: 'Failed to fetch content' },
{ status: 500 }
);
}
}
Now let's create an endpoint for getting a specific content item:
mkdir -p src/app/api/content/[type]/[slug]/route.ts
touch src/app/api/content/[type]/[slug]/route.ts
Open this file and add:
import { NextRequest, NextResponse } from 'next/server';
import { getFileContent } from '@/lib/mdx';
export async function GET(
request: NextRequest,
{ params }: { params: { type: string; slug: string } }
) {
try {
const { type, slug } = params;
const content = await getFileContent(type, `${slug}.mdx`);
return NextResponse.json({
metadata: content.metadata,
slug: content.slug,
});
} catch (error) {
return NextResponse.json(
{ error: 'Failed to fetch content' },
{ status: 500 }
);
}
}
With these API routes, we now have a programmatic way to access our content, making our system truly headless.
📱 Building the Frontend Views
Now, let's create the frontend views to display our content. First, let's create a page to list all blog posts:
mkdir -p src/app/blog/page.tsx
touch src/app/blog/page.tsx
Open this file and add:
import Link from 'next/link';
import { getAllContent } from '@/lib/mdx';
export default async function BlogPage() {
const posts = await getAllContent('blog');
return (
<div className="max-w-4xl mx-auto py-12 px-4">
<h1 className="text-3xl font-bold mb-8">Blog Posts</h1>
<div className="grid gap-8">
{posts.map((post: any) => (
<article key={post.slug} className="border rounded-lg p-6 shadow-sm">
<h2 className="text-xl font-semibold mb-2">
<Link href={`/blog/${post.slug}`} className="text-blue-600 hover:text-blue-800">
{post.title}
</Link>
</h2>
<p className="text-gray-600 mb-4">{post.description}</p>
<div className="flex items-center text-sm text-gray-500">
<span>{new Date(post.date).toLocaleDateString()}</span>
<span className="mx-2">•</span>
<span>{post.author}</span>
</div>
</article>
))}
</div>
</div>
);
}
Next, let's create a page to display a single blog post:
mkdir -p src/app/blog/[slug]/page.tsx
touch src/app/blog/[slug]/page.tsx
Open this file and add:
import { MDXRemote } from 'next-mdx-remote/rsc';
import { getFileContent, getAllFiles } from '@/lib/mdx
Written by
Marcus Ruud
At
Thu Nov 09 2023