In modern web development, the headless architecture reigns supreme. Decoupling your content backend (the "head") from your presentation layer (the "body") provides unparalleled flexibility. But this freedom introduces a new challenge: how do you render rich, component-driven content from a CMS that only gives you a string?
Enter MDX. It's the superset of Markdown that allows you to embed JSX components directly into your content, turning static text into dynamic, interactive experiences. The only remaining hurdle is compiling that MDX on the fly.
This is where mdx.do shines. It's a powerful API designed for one purpose: to dynamically compile MDX content into executable React code without a complex local build step.
In this step-by-step tutorial, we'll build a powerful, flexible headless blog by integrating the mdx.do API with Next.js, showcasing how simple it can be to create rich, component-driven content experiences.
This trio forms a powerful combination for building next-generation web applications:
mdx.do is the critical bridge that connects your headless content to your Next.js frontend, handling the complex MDX compilation process through a simple API call.
Before we start, make sure you have:
Let's begin by creating a new Next.js application. We'll use the App Router, which is perfect for this kind of server-centric data fetching.
npx create-next-app@latest mdx-do-blog
Follow the prompts, selecting TypeScript and Tailwind CSS if you'd like. Once it's done, navigate into your new project directory:
cd mdx-do-blog
In a real-world application, you'd fetch content from a Headless CMS. To keep this tutorial focused and self-contained, we'll simulate a CMS with a simple file.
Create a new folder and file: lib/cms.ts. Paste the following code into it.
// lib/cms.ts
const posts = [
{
slug: 'hello-world',
content: `---
title: "Hello, World!"
date: "2024-01-15"
author: "mdx.do"
---
# Welcome to the Future of Content!
This is your first post rendered dynamically using **mdx.do**. You can write standard markdown, but with a twist.
You can also render React components. Let's add an interactive element:
<Callout type="success">
This is a custom React component rendered from a string! Expressions are easy too: The current year is {new Date().getFullYear()}.
</Callout>
`
},
{
slug: 'why-mdx-is-awesome',
content: `---
title: "Why MDX is a Game-Changer"
date: "2024-02-20"
author: "mdx.do"
---
## Component-Driven Content
MDX blurs the line between static content and interactive applications. Instead of being limited to \`<img>\` tags, you can create a powerful \`<ImageGallery />\` component.
### Before MDX:
\`\`\`html
<img src="/chart.png" alt="Static Chart">
\`\`\`
### With MDX:
\`\`\`jsx
<InteractiveChart data={[30, 10, 80, 50, 60]} />
\`\`\`
This flexibility is the core of modern content experiences.
`
}
];
// Helper functions to simulate a real CMS API
export const getPostBySlug = async (slug: string) => {
return posts.find(post => post.slug === slug);
}
export const getAllPosts = async () => {
// In a real CMS, you'd fetch metadata here.
// For our simulation, we'll just return the slugs.
return posts.map(post => ({ slug: post.slug, title: post.content.match(/title: "([^"]+)"/)?.[1] }));
}
This file mimics fetching a post by its slug and getting a list of all available posts. Notice the MDX content includes YAML frontmatter and a custom <Callout> component.
Now for the magic. We'll create a dynamic route that fetches a post, sends its content to mdx.do for compilation, and prepares it for rendering.
First, let's install the tools we need. mdx.do provides a simple client for its API, and we'll use next-mdx-remote for safely rendering the compiled code.
npm install @mdx-do/client next-mdx-remote
(Note: @mdx-do/client is a hypothetical package for this tutorial, representing the easy-to-use client for the mdx.do service.)
Next, create the dynamic blog post page at app/blog/[slug]/page.tsx.
// app/blog/[slug]/page.tsx
import { getPostBySlug } from '@/lib/cms';
import { mdx } from "@mdx.do/client";
import { notFound } from 'next/navigation';
// Our custom components will be defined here
import Callout from '@/components/Callout';
export default async function PostPage({ params }: { params: { slug: string } }) {
const post = await getPostBySlug(params.slug);
if (!post) {
notFound();
}
// 1. Fetch content from our CMS
const mdxContent = post.content;
// 2. Compile the MDX string using the mdx.do API
const { code, frontmatter } = await mdx.compile(mdxContent);
// `code` is now executable JS ready to be rendered!
// `frontmatter` is a parsed object from the YAML block.
return (
<article className="prose lg:prose-xl mx-auto py-12">
<h1>{frontmatter.title}</h1>
<p>By {frontmatter.author} on {new Date(frontmatter.date).toLocaleDateString()}</p>
<hr />
{/* 3. The content will be rendered here in the next step */}
</article>
);
}
In just a few lines, we've implemented the core logic. We get the raw MDX string from our "CMS" and pass it to mdx.compile(). This function communicates with the mdx.do service and returns two crucial pieces of data:
The code returned by mdx.do is a string of JavaScript. To render it safely in a React Server Component, we use next-mdx-remote. It's designed for exactly this purpose.
Let's complete our PostPage component.
// app/blog/[slug]/page.tsx
import { getPostBySlug, getAllPosts } from '@/lib/cms';
import { mdx } from "@mdx.do/client";
import { notFound } from 'next/navigation';
import { MDXRemote } from 'next-mdx-remote/rsc'; // Import RSC version
// We'll create this component in the next step
import Callout from '@/components/Callout';
import InteractiveChart from '@/components/InteractiveChart';
// Statically generate routes at build time
export async function generateStaticParams() {
const posts = await getAllPosts();
return posts.map((post) => ({
slug: post.slug,
}));
}
export default async function PostPage({ params }: { params: { slug: string } }) {
const post = await getPostBySlug(params.slug);
if (!post) {
notFound();
}
const { code, frontmatter } = await mdx.compile(post.content);
// Map your custom components to the names used in MDX
const components = {
Callout,
InteractiveChart,
};
return (
<article className="prose dark:prose-invert lg:prose-xl mx-auto py-12 px-4">
<h1>{frontmatter.title}</h1>
<p className="text-gray-500">By {frontmatter.author} on {new Date(frontmatter.date).toLocaleDateString()}</p>
<hr className="my-8"/>
{/* Render the compiled code, providing our custom components */}
<MDXRemote source={code} components={components} />
</article>
);
}
The key piece here is <MDXRemote />. We pass the compiled code to its source prop, and a map of our local React components to the components prop. next-mdx-remote handles the rest, safely evaluating the code and rendering the resulting React elements.
This is where the magic of MDX truly comes to life. Let's create the <Callout /> component we referenced in our content.
Create a new file at components/Callout.tsx:
// components/Callout.tsx
import React from 'react';
interface CalloutProps {
type?: 'info' | 'success' | 'warning' | 'danger';
children: React.ReactNode;
}
const styles = {
info: 'bg-blue-100 border-blue-500 text-blue-800',
success: 'bg-green-100 border-green-500 text-green-800',
warning: 'bg-yellow-100 border-yellow-500 text-yellow-800',
danger: 'bg-red-100 border-red-500 text-red-800',
};
export default function Callout({ children, type = 'info' }: CalloutProps) {
return (
<div className={`border-l-4 p-4 my-6 rounded-r-md ${styles[type]}`} role="alert">
{children}
</div>
);
}
Now, when you navigate to /blog/hello-world, mdx.do will compile the string <Callout>...</Callout>, and MDXRemote will find our React component in the components map and render it perfectly. You've just achieved component-driven content.
To complete our blog, let's create an index page that lists all our posts.
Create app/blog/page.tsx:
// app/blog/page.tsx
import Link from 'next/link';
import { getAllPosts } from '@/lib/cms';
export default async function BlogIndex() {
const posts = await getAllPosts();
return (
<main className="max-w-2xl mx-auto py-12 px-4">
<h1 className="text-4xl font-bold mb-8">Blog</h1>
<div className="space-y-4">
{posts.map((post) => (
<Link
key={post.slug}
href={`/blog/${post.slug}`}
className="block p-4 border rounded-md hover:bg-gray-50 dark:hover:bg-gray-800 transition-colors"
>
<h2 className="text-2xl font-semibold">{post.title}</h2>
</Link>
))}
</div>
</main>
);
}
This page fetches the metadata for all posts and renders a list of links. generateStaticParams in our [slug] page ensures these pages are pre-built for maximum performance.
With our blog complete, let's recap the benefits of this architecture:
You've successfully built a modern, dynamic, and powerful blog. You can now swap out the simulated CMS for a real headless provider and start shipping incredible content experiences, powered by the simplicity and power of mdx.do.