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

MDX вместо headless CMS

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

Если контент пишете вы, а не редактор из маркетинга, админка Contentful часто лишняя. Файлы в Git, Zod на сборке, React в тексте.

MDX вместо headless CMS

Запускаешь блог или портфолио. Первая мысль: нужна CMS. Contentful, Strapi, Sanity. Схемы, API, счёт за план.

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

Если контент создают разработчики, headless CMS часто лишний слой.

MDX: Markdown плюс JSX. Обычный текст, внутри него React.

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

Компоненты становятся HTML на сборке или на сервере. Внешнего API нет.

Когда файлы выигрывают

Документация, блог разработчика, портфолио. Контент в Git: blame, ревью, откат. Маркдаун родной. Подсветка кода из коробки. Нет задержки API и лимитов.

Headless CMS
MDX в Git
Workflow
CMS админка → API → фронт
MDX файл → commit → merge
Деплой контента
Webhook + пересборка
Обычный git push
Откат изменений
Вручную или сложно
git revert

Десятки статей, не тысячи карточек товара. Меньше сотни файлов сборка почти не замечает.

CMS даёт rich text. MDX даёт ваши компоненты.

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

Никаких custom blocks через JSON-схемы.

Frontmatter проверяется на сборке, не в рантайме.

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

Кривая дата или пустой заголовок: сборка падает. В CMS об этом узнает пользователь.

Компоненты

Обычный 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 в корне.

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 ту же ошибку увидит человек на странице.

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

Все операции с контентом живут в утилитах, не в страницах.

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

Они не знают про app/. Их можно звать из API, SSR, 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 на билде. Задержки на контент нет.

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

CMS всё ещё нужна, когда пишут нетехнари, единиц контента тысячи, нужен поиск и воронка draft → review → publish, правки по несколько раз в день или переводы делают разные люди.

Как устроен этот сайт

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

Файлы в content/posts/ и content/projects/. Zod на frontmatter. Callout, MetricsGrid, BeforeAfter, TechStack. SSG через App Router. Русский файл обязателен, английский *.en.mdx по желанию.

Пишу в VSCode. git commit валит кривой frontmatter. git push собирает статику. PM2 reload без окна.

От идеи до публикации около десяти минут. Без логина в админку и без месячного счёта.

Как начать

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

Сто с лишним файлов: сборка уже секунд десять. Next кеширует парсинг. На больших объёмах лучше next-mdx-remote/rsc, тяжёлые remark/rehype только там, где нужны.

Превью без npm run dev нет. В VSCode есть MDX Preview. Поверх файлов можно положить Tina или Keystatic, если очень хочется окошко.

Поиска из коробки нет. На сборке индексирую в JSON и ищу на клиенте через Fuse.js. Для опенсорса ещё Algolia DocSearch. Lunr.js, если индекс должен быть статическим.

MDX не заменяет CMS везде. Для технического сайта с контролем над кодом это type-safe контент, git и React без SaaS.

См. также: