Skip to main content

MDX as headless CMS Alternative: When Code Beats Admin Panel

Constantin Potapov
10 min

When MDX files in your repo work better than Contentful or Strapi. Exploring type-safe content, custom components, and content layer architecture — without unnecessary abstractions and monthly bills.

MDX as headless CMS Alternative: When Code Beats Admin Panel

When Admin Panel Becomes a Burden

You know this story? Starting a blog, portfolio, or documentation site. First thought: "I need a CMS". Then choosing between Contentful, Strapi, Sanity... Setting up schemas, wiring APIs, paying for hosting or SaaS plans.

A month later you realize: the admin panel is used once a week, you (a technical person) write the content, and half the time goes into fighting field builder limitations.

If developers create content, not editors — headless CMS might be overkill. MDX gives you control without extra layers.

What is MDX and Why It Matters

MDX = Markdown + JSX. Write regular markdown, but embed React components directly in text:

## Article Heading
 
Regular paragraph text.
 
<MetricsGrid
  metrics={[
    { value: "×3", label: "production" },
    { value: "99.9%", label: "uptime" }
  ]}
/>
 
More text continues...

Components render to HTML at build time (if SSG) or server-side (SSR). No runtime overhead, no external API calls.

When MDX Beats Headless CMS

1. Technical Content

Documentation, developer blogs, portfolios:

  • Content in Git → version control, code review, blame
  • Markdown is native format for technical writing
  • Code snippets with syntax highlighting out of the box
  • No API delays, no rate limits
Headless CMS
MDX in Git
Workflow
CMS admin → API → frontend
MDX file → commit → merge
Content deploy
Webhook + rebuild
Regular git push
Rollback changes
Manual or complex
git revert

2. Small Content Volumes

If you have dozens of articles, not thousands of products — MDX handles it without indexes and search. Less than 100 files? Build is instant.

3. Custom Components Needed

CMS gives you "rich text editor". MDX gives you full React arsenal:

<Callout type="warning">
Important warning with icon and color
</Callout>
 
<BeforeAfter before={[...]} after={[...]} />
 
<TechStack stack={["React", "TypeScript"]} />
 
<Video src="https://..." title="Demo" />

Any component from your design system — directly in content. No "custom blocks" via JSON schemas.

4. Type-Safety Required

MDX + TypeScript = type-safe content. Frontmatter validated at build time:

// src/shared/lib/mdx/posts.ts
type PostFrontmatter = {
  title: string;
  slug: string;
  date: string;
  summary: string;
  tags: string[];
  featured?: boolean;
  draft?: boolean;
};
 
// Parsing with validation via Zod or similar
export async function getPostBySlug(slug: string) {
  const source = await readFile(`content/posts/${slug}.mdx`);
  const { data, content } = matter(source);
 
  // Type-safe frontmatter
  const frontmatter = PostFrontmatterSchema.parse(data);
 
  return { frontmatter, content };
}

Error in date format or missing required field — build fails. With CMS — you find out at runtime.

Custom Components for MDX

Creating a Component

MDX components are regular React components:

// src/shared/ui/mdx-components.tsx
export function Callout({
  type = "info",
  children
}: {
  type?: "info" | "warning" | "success" | "error";
  children: React.ReactNode;
}) {
  return (
    <div className={cn(
      "rounded-[var(--radius)] border p-4",
      type === "warning" && "border-yellow-500 bg-yellow-50",
      type === "error" && "border-red-500 bg-red-50",
      // ...
    )}>
      {children}
    </div>
  );
}

Registering Components

// src/shared/ui/mdx-components.tsx
export const mdxComponents = {
  Callout,
  MetricsGrid,
  TechStack,
  BeforeAfter,
  Quote,
  Video,
  Gallery,
  // Override standard elements
  h1: (props) => <h1 className="text-4xl font-bold" {...props} />,
  a: (props) => <a className="text-primary hover:underline" {...props} />,
};

Using in MDX

After registration, components available without imports:

---
title: "Article"
---
 
## Section
 
<Callout type="warning">
Automatically available!
</Callout>

Next.js + @next/mdx does this out of the box via mdx-components.tsx in project root.

Type-Safe Frontmatter Handling

Validation Schema (Zod)

// src/shared/lib/mdx/schema.ts
import { z } from "zod";
 
export const PostFrontmatterSchema = z.object({
  title: z.string().min(1),
  slug: z.string().regex(/^[a-z0-9-]+$/),
  date: z.string().regex(/^\d{4}-\d{2}-\d{2}$/),
  summary: z.string().min(10).max(300),
  tags: z.array(z.string()).min(1),
  featured: z.boolean().optional(),
  draft: z.boolean().optional(),
  readTime: z.string().optional(),
  author: z.string().optional(),
});
 
export type PostFrontmatter = z.infer<typeof PostFrontmatterSchema>;

Parsing with Validation

// src/shared/lib/mdx/posts.ts
import matter from "gray-matter";
import { PostFrontmatterSchema } from "./schema";
 
export async function getAllPosts() {
  const files = await readdir("content/posts");
  const posts = await Promise.all(
    files
      .filter((f) => f.endsWith(".mdx") && !f.endsWith(".en.mdx"))
      .map(async (file) => {
        const source = await readFile(`content/posts/${file}`);
        const { data } = matter(source);
 
        // Validation at build time
        const frontmatter = PostFrontmatterSchema.parse(data);
 
        // Filter drafts in production
        if (process.env.NODE_ENV === "production" && frontmatter.draft) {
          return null;
        }
 
        return frontmatter;
      })
  );
 
  return posts
    .filter(Boolean)
    .sort((a, b) => b.date.localeCompare(a.date));
}

Frontmatter error → build fails → you know before deploy. With CMS — you find out when user sees 500.

Typing in Components

// app/blog/page.tsx
import { getAllPosts } from "@/shared/lib/mdx/posts";
 
export default async function BlogPage() {
  const posts = await getAllPosts();
 
  return (
    <div>
      {posts.map((post) => (
        // post.title, post.slug — fully typed
        <PostCard key={post.slug} {...post} />
      ))}
    </div>
  );
}

Content Layer Architecture

Project Structure

content/
  posts/
    *.mdx              # English versions
    *.ru.mdx           # Russian versions
  projects/
    *.mdx
  pages/
    *.mdx

src/
  shared/
    lib/
      mdx/
        posts.ts       # Post utilities
        projects.ts    # Project utilities
        schema.ts      # Zod schemas
    ui/
      mdx-components.tsx  # MDX components

app/
  blog/
    page.tsx           # Post list
    [slug]/page.tsx    # Post page (SSG)

Content Utilities Layer

All content operations through utilities:

// src/shared/lib/mdx/posts.ts
export async function getAllPosts(): Promise<PostFrontmatter[]>
export async function getFeaturedPosts(): Promise<PostFrontmatter[]>
export async function getPostBySlug(slug: string): Promise<Post>
export async function getAllPostSlugs(): Promise<string[]>

Isolation from Next.js:

  • Utilities don't know about app/ directory
  • Can reuse in API routes, SSR, SSG
  • Easy to test

SSG for Posts

// app/blog/[slug]/page.tsx
import { getAllPostSlugs, getPostBySlug } from "@/shared/lib/mdx/posts";
import { compileMDX } from "next-mdx-remote/rsc";
import { mdxComponents } from "@/shared/ui/mdx-components";
 
// Generate static paths
export async function generateStaticParams() {
  const slugs = await getAllPostSlugs();
  return slugs.map((slug) => ({ slug }));
}
 
// Generate metadata
export async function generateMetadata({ params }) {
  const post = await getPostBySlug(params.slug);
  return {
    title: post.frontmatter.title,
    description: post.frontmatter.summary,
  };
}
 
// Render page
export default async function PostPage({ params }) {
  const { frontmatter, content } = await getPostBySlug(params.slug);
 
  const { content: mdxContent } = await compileMDX({
    source: content,
    components: mdxComponents,
  });
 
  return (
    <article>
      <h1>{frontmatter.title}</h1>
      <div className="prose">{mdxContent}</div>
    </article>
  );
}

Result: all posts built to HTML at build time. Zero content load time, excellent SEO.

Comparison: MDX vs Headless CMS

Headless CMS
MDX in Git
Load speed
API request + render
0ms (SSG)
Time to market
CMS setup + schemas
Created .mdx file
Version control
None or complex
Git out of the box
Complexity
API + typing + cache
File in repo
Cost
$29-299/mo
$0
100%

When You Still Need CMS

  • Non-technical editors (marketing, copywriters)
  • Thousands of content items
  • Need search, filtering, tags
  • Content updates multiple times daily
  • Workflow required: draft → review → publish
  • Multi-language with translations by different people

Real Example: This Website

This website (potapov.me) uses MDX for projects and posts:

~50
MDX files
< 1s
build time
100%
type-safe
$0
for CMS

Content stack:

  • MDX files in content/posts/, content/projects/
  • Frontmatter validation via Zod
  • Custom components: Callout, MetricsGrid, BeforeAfter, TechStack
  • SSG via Next.js App Router
  • i18n: *.mdx (Russian) + *.en.mdx (English)

Workflow:

  1. Write article in VSCode (highlighting, preview)
  2. git commit → automatic frontmatter validation
  3. git push → CI builds static
  4. Deploy via PM2 reload (zero-downtime)

From idea to publish — 10 minutes. No admin panel logins, no API tokens, no monthly bills.

Getting Started with MDX

1. Installation (Next.js)

npm install @next/mdx @mdx-js/loader @mdx-js/react gray-matter
npm install -D @types/mdx

2. Configure Next.js

// next.config.ts
import createMDX from "@next/mdx";
 
const withMDX = createMDX({
  extension: /\.mdx?$/,
  options: {
    remarkPlugins: [],
    rehypePlugins: [],
  },
});
 
export default withMDX({
  pageExtensions: ["ts", "tsx", "md", "mdx"],
});

3. Create First MDX File

---
title: "First Post"
slug: "first-post"
date: "2025-11-14"
summary: "Testing MDX"
tags: ["test"]
---
 
## Heading
 
Regular text.
 
<Callout type="info">
Custom component!
</Callout>

4. Reading Utility

// src/shared/lib/mdx/posts.ts
import fs from "fs/promises";
import path from "path";
import matter from "gray-matter";
 
const POSTS_DIR = path.join(process.cwd(), "content/posts");
 
export async function getAllPosts() {
  const files = await fs.readdir(POSTS_DIR);
  const posts = await Promise.all(
    files
      .filter((f) => f.endsWith(".mdx"))
      .map(async (file) => {
        const content = await fs.readFile(
          path.join(POSTS_DIR, file),
          "utf-8"
        );
        const { data } = matter(content);
        return data;
      })
  );
  return posts;
}

5. Render in Next.js

// app/blog/[slug]/page.tsx
import { compileMDX } from "next-mdx-remote/rsc";
 
export default async function Post({ params }) {
  const source = await readPostFile(params.slug);
  const { content } = await compileMDX({ source });
 
  return <article>{content}</article>;
}

Pitfalls and Solutions

Pitfall #1: Slow Build

Problem: 100+ MDX files → 10+ second builds

Solution:

  • Cache parsing (Next.js does automatically)
  • Use next-mdx-remote/rsc instead of @next/mdx for large volumes
  • Apply heavy rehype/remark plugins only to necessary files

Pitfall #2: No Live Preview

Problem: Need npm run dev + F5 to see changes

Solution:

  • VSCode MDX Preview extension
  • Or use CMS UI on top of MDX (Tina CMS, Keystatic)

Problem: MDX is static files, no full-text search

Solution:

  • Index at build time to JSON → client-side search (Fuse.js)
  • Algolia DocSearch (free for open source)
  • Lunr.js for static indexes

Conclusion

MDX isn't a CMS replacement for all cases. But for technical projects with code control it provides:

Type-safety — errors at build time, not in production ✅ Git workflow — version control, code review, rollback ✅ Custom components — full React without limitations ✅ Zero latency — SSG, no API requests ✅ Zero cost — no SaaS subscriptions

If developers write content, not editors — MDX can be simpler, faster, and cheaper than headless CMS. Check if it's your case.

See also: