Skip to main content

MDX instead of a headless CMS

Constantin Potapov
9 min

If you write the content, not a marketing editor, a Contentful admin is often an extra layer. Files in Git, Zod at build time, React in the text.

MDX instead of a headless CMS

You start a blog or a portfolio. First thought: you need a CMS. Contentful, Strapi, Sanity. Schemas, an API, a plan on the invoice.

A month later the admin opens once a week. You write the content, a technical person. Half the time goes into fighting the field builder.

If developers create the content, a headless CMS is often an extra layer.

MDX: Markdown plus JSX. Ordinary text, React inside it.

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

Components become HTML at build time or on the server. No external API.

When files win

Docs, a developer blog, a portfolio. Content in Git: blame, review, revert. Markdown is native. Syntax highlighting out of the box. No API delay, 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

Dozens of articles, not thousands of product cards. Under a hundred files the build barely notices.

A CMS gives you rich text. MDX gives you your components.

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

No custom blocks through JSON schemas.

Frontmatter is checked at build time, not at runtime.

// 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 };
}

A crooked date or an empty title: the build dies. In a CMS a user finds out.

Components

Ordinary React.

// 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>
  );
}
// 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} />,
};

After registration, MDX needs no imports:

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

Next.js and @next/mdx do this through mdx-components.tsx at the root.

Zod on frontmatter

// 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>;
// 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));
}

A frontmatter error kills the build. In a CMS the same error reaches a person on the page.

// 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 and post.slug are typed
        <PostCard key={post.slug} {...post} />
      ))}
    </div>
  );
}

A utilities layer

content/
  posts/
    *.mdx
    *.en.mdx
  projects/
    *.mdx
  pages/
    *.mdx

src/
  shared/
    lib/
      mdx/
        posts.ts
        projects.ts
        schema.ts
    ui/
      mdx-components.tsx

app/
  blog/
    page.tsx
    [slug]/page.tsx

All content operations live in utilities, not in pages.

// 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[]>;

They do not know about app/. You can call them from an API, SSR, SSG and tests.

// 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>
  );
}

Posts become HTML at build time. No wait for content.

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%

You still need a CMS when non-technical people write, there are thousands of items, you need search and a draft → review → publish funnel, edits happen several times a day, or different people do the translations.

How this site is built

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

Files in content/posts/ and content/projects/. Zod on frontmatter. Callout, MetricsGrid, BeforeAfter, TechStack. SSG through the App Router. The Russian file is required, English *.en.mdx is optional.

I write in VSCode. git commit kills crooked frontmatter. git push builds static. PM2 reload without a window.

From idea to publish about ten minutes. No admin login, no monthly bill.

How to start

npm install @next/mdx @mdx-js/loader @mdx-js/react gray-matter
npm install -D @types/mdx
// next.config.ts
import createMDX from "@next/mdx";
 
const withMDX = createMDX({
  extension: /\.mdx?$/,
  options: {
    remarkPlugins: [],
    rehypePlugins: [],
  },
});
 
export default withMDX({
  pageExtensions: ["ts", "tsx", "md", "mdx"],
});
---
title: "First Post"
slug: "first-post"
date: "2025-11-14"
summary: "Testing MDX"
tags: ["test"]
---
 
## Heading
 
Regular text.
 
<Callout type="info">Custom component!</Callout>
// 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;
}
// 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>;
}

A hundred-plus files: the build is already ten seconds. Next caches parsing. At larger volumes next-mdx-remote/rsc is better, heavy remark/rehype only where needed.

There is no preview without npm run dev. VSCode has MDX Preview. You can put Tina or Keystatic on top of the files if you really want a window.

There is no search out of the box. At build time I index to JSON and search on the client with Fuse.js. For open source there is also Algolia DocSearch. Lunr.js if the index must be static.

MDX does not replace a CMS everywhere. For a technical site with control over the code it is type-safe content, git and React without SaaS.

See also: