Запускаешь блог или портфолио. Первая мысль: нужна 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 и лимитов.
Десятки статей, не тысячи карточек товара. Меньше сотни файлов сборка почти не замечает.
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 на билде. Задержки на контент нет.
CMS всё ещё нужна, когда пишут нетехнари, единиц контента тысячи, нужен поиск и воронка draft → review → publish, правки по несколько раз в день или переводы делают разные люди.
Как устроен этот сайт
Файлы в 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.
См. также:



