Перейти к содержимому

MDX как альтернатива headless CMS: Когда код лучше админки

Константин Потапов
10 мин

Когда MDX-файлы в репозитории работают лучше, чем Contentful или Strapi. Рассказываю про type-safe контент, кастомные компоненты и content layer архитектуру — без лишних абстракций и месячных счетов.

MDX как альтернатива headless CMS: Когда код лучше админки

Когда админка становится обузой

Помните эту историю? Запускаете блог, портфолио или документацию. Первая мысль: «Нужна CMS». Дальше — выбор между Contentful, Strapi, Sanity... Настраиваете схемы, прикручиваете API, платите за хостинг или SaaS-план.

Через месяц понимаете: админка используется раз в неделю, контент пишете вы (технический человек), а половина времени уходит на борьбу с ограничениями конструктора полей.

Если контент создают разработчики, а не редакторы — headless CMS может быть избыточен. MDX даёт контроль без лишних слоёв.

Что такое MDX и почему он здесь

MDX = Markdown + JSX. Пишете обычный маркдаун, но можете вставлять React-компоненты прямо в текст:

## Заголовок статьи
 
Обычный текст параграфа.
 
<MetricsGrid
  metrics={[
    { value: "×3", label: "production" },
    { value: "99.9%", label: "uptime" },
  ]}
/>
 
Продолжение текста...

Компоненты рендерятся в HTML на этапе сборки (если SSG) или на сервере (SSR). Никакого рантайма, никаких внешних API.

Когда MDX лучше headless CMS

1. Технический контент

Документация, блоги разработчиков, портфолио:

  • Контент в Git → version control, code review, blame
  • Markdown — родной формат для техписов
  • Вставки кода с подсветкой синтаксиса из коробки
  • Нет задержек API, нет rate limits
Headless CMS
MDX в Git
Workflow
CMS админка → API → фронт
MDX файл → commit → merge
Деплой контента
Webhook + пересборка
Обычный git push
Откат изменений
Вручную или сложно
git revert

2. Небольшие объёмы контента

Если у вас десятки статей, а не тысячи продуктов — MDX справится без индексов и поиска. Меньше 100 файлов? Сборка мгновенная.

3. Нужны кастомные компоненты

CMS даёт «rich text editor». MDX даёт весь арсенал React:

<Callout type="warning">Важное предупреждение с иконкой и цветом</Callout>
 
<BeforeAfter before={[...]} after={[...]} />
 
<TechStack stack={["React", "TypeScript"]} />
 
<Video src="https://..." title="Демо" />

Любой компонент из вашей дизайн-системы — сразу в контент. Никаких «custom blocks» через JSON-схемы.

4. Нужен type-safety

MDX + TypeScript = type-safe контент. Фронтматтер проверяется на этапе сборки:

// src/shared/lib/mdx/posts.ts
type PostFrontmatter = {
  title: string;
  slug: string;
  date: string;
  summary: string;
  tags: string[];
  featured?: boolean;
  draft?: boolean;
};
 
// Парсинг с валидацией через Zod или подобное
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 };
}

Ошибка в date формате или отсутствие обязательного поля — сборка упадёт. В CMS — узнаете в рантайме.

Кастомные компоненты для MDX

Создание компонента

Компоненты для MDX — обычные 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,
  // Переопределяем стандартные элементы
  h1: (props) => <h1 className="text-4xl font-bold" {...props} />,
  a: (props) => <a className="text-primary hover:underline" {...props} />,
};

Использование в MDX

После регистрации компоненты доступны без импортов:

---
title: "Статья"
---
 
## Секция
 
<Callout type="warning">Автоматически доступно!</Callout>

Next.js + @next/mdx делает это из коробки через mdx-components.tsx в корне проекта.

Type-safe работа с frontmatter

Схема валидации (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>;

Парсинг с валидацией

// 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);
 
        // Валидация на этапе сборки
        const frontmatter = PostFrontmatterSchema.parse(data);
 
        // Фильтруем черновики в проде
        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 → сборка упадёт → вы узнаете до деплоя. В CMS — узнаете когда пользователь увидит 500.

Типизация в компонентах

// 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 — все типизированы
        <PostCard key={post.slug} {...post} />
      ))}
    </div>
  );
}

Content layer архитектура

Структура проекта

content/
  posts/
    *.mdx              # Русские версии
    *.en.mdx           # Английские версии
  projects/
    *.mdx
  pages/
    *.mdx

src/
  shared/
    lib/
      mdx/
        posts.ts       # Утилиты для постов
        projects.ts    # Утилиты для проектов
        schema.ts      # Zod-схемы
    ui/
      mdx-components.tsx  # MDX компоненты

app/
  blog/
    page.tsx           # Список постов
    [slug]/page.tsx    # Страница поста (SSG)

Content utilities layer

Все операции с контентом — через утилиты:

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

Изоляция от Next.js:

  • Утилиты не знают про app/ директорию
  • Можно переиспользовать в API routes, SSR, SSG
  • Легко тестировать

SSG для постов

// 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";
 
// Генерация статических путей
export async function generateStaticParams() {
  const slugs = await getAllPostSlugs();
  return slugs.map((slug) => ({ slug }));
}
 
// Генерация metadata
export async function generateMetadata({ params }) {
  const post = await getPostBySlug(params.slug);
  return {
    title: post.frontmatter.title,
    description: post.frontmatter.summary,
  };
}
 
// Рендер страницы
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>
  );
}

Результат: все посты собираются в HTML на этапе билда. Нулевое время загрузки контента, отличный SEO.

Сравнение: MDX vs headless CMS

Headless CMS
MDX в Git
Скорость загрузки
API запрос + рендер
0ms (SSG)
Time to market
Настройка CMS + схемы
Создал .mdx файл
Version control
Нет или сложно
Git из коробки
Сложность
API + типизация + кеш
Файл в репо
Стоимость
$29-299/мес
$0
100%

Когда всё-таки нужна CMS

  • Нетехнические редакторы (маркетинг, копирайтеры)
  • Тысячи единиц контента
  • Нужен поиск, фильтрация, теги
  • Контент обновляется несколько раз в день
  • Требуется workflow: draft → review → publish
  • Мультиязычность с переводами от разных людей

Практический пример: этот сайт

Этот сайт (potapov.me) использует MDX для проектов и постов:

~50
MDX файлов
< 1s
время сборки
100%
type-safe
$0
за CMS

Стек контента:

  • MDX файлы в content/posts/, content/projects/
  • Frontmatter валидация через Zod
  • Кастомные компоненты: Callout, MetricsGrid, BeforeAfter, TechStack
  • SSG через Next.js App Router
  • i18n: *.mdx (русский) + *.en.mdx (английский)

Workflow:

  1. Пишу статью в VSCode (подсветка, превью)
  2. git commit → автоматически валидация frontmatter
  3. git push → CI собирает статику
  4. Деплой через PM2 reload (zero-downtime)

От идеи до публикации — 10 минут. Без логинов в админки, без API токенов, без месячных счетов.

Как начать с MDX

1. Установка (Next.js)

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

2. Конфигурация 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. Создайте первый MDX файл

---
title: "Первый пост"
slug: "first-post"
date: "2025-11-14"
summary: "Тестируем MDX"
tags: ["test"]
---
 
## Заголовок
 
Обычный текст.
 
<Callout type="info">Кастомный компонент!</Callout>

4. Утилита для чтения

// 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. Рендер в 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>;
}

Грабли и решения

Граблі #1: Медленная сборка

Проблема: 100+ MDX файлов → сборка 10+ секунд

Решение:

  • Кешируйте парсинг (Next.js делает автоматически)
  • Используйте next-mdx-remote/rsc вместо @next/mdx для больших объёмов
  • Выносите тяжелые rehype/remark плагины только для необходимых файлов

Граблі #2: Нет live preview

Проблема: Нужен npm run dev + F5 для просмотра изменений

Решение:

  • VSCode расширение MDX Preview
  • Или используйте CMS UI поверх MDX (Tina CMS, Keystatic)

Граблі #3: Поиск по контенту

Проблема: MDX — статические файлы, нет полнотекстового поиска

Решение:

  • Индексируйте на этапе сборки в JSON → клиентский поиск (Fuse.js)
  • Algolia DocSearch (бесплатно для опенсорса)
  • Lunr.js для статических индексов

Итог

MDX — не замена CMS для всех случаев. Но для технических проектов с контролем над кодом он даёт:

Type-safety — ошибки на этапе сборки, не в продакшене ✅ Git workflow — version control, code review, rollback ✅ Кастомные компоненты — весь React без ограничений ✅ Нулевая задержка — SSG, никаких API запросов ✅ Нулевая стоимость — нет SaaS подписок

Если контент пишут разработчики, а не редакторы — MDX может быть проще, быстрее и дешевле headless CMS. Проверьте, может быть ваш случай.

См. также: