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
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
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:
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:
- Write article in VSCode (highlighting, preview)
git commit→ automatic frontmatter validationgit push→ CI builds static- 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/mdx2. 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/rscinstead of@next/mdxfor 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)
Pitfall #3: Content Search
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:
- Feature-Sliced Design — how to structure MDX utilities by layers
- Slot-Me — booking platform — project with MDX content in production


