Этот блог собран ровно так, как описано ниже, — поэтому статья получилась не обзором возможностей, а разбором принятых решений с их последствиями. Часть из них я бы принял так же, часть — нет, и об этом тоже будет сказано.
Почему MDX
MDX — это Markdown с поддержкой React-компонентов. Идеально для технического блога:
- Markdown — пишем текст как обычно
- JSX — вставляем интерактивные компоненты
- Типизация — frontmatter с TypeScript
- Git — статьи версионируются вместе с кодом
Этот блог построен именно так. Все статьи — MDX-файлы в репозитории.
Структура проекта
blog/
├── app/
│ ├── layout.tsx
│ ├── page.tsx
│ └── blog/
│ ├── page.tsx
│ └── [slug]/
│ └── page.tsx
├── content/
│ └── articles/
│ ├── first-post.mdx
│ └── second-post.mdx
├── components/
│ ├── mdx/
│ │ ├── InfoBox.tsx
│ │ ├── CodeBlock.tsx
│ │ └── index.tsx
│ └── TableOfContents.tsx
├── lib/
│ ├── articles.ts
│ └── mdx.ts
└── package.jsonУстановка зависимостей
npm install @mdx-js/mdx @mdx-js/react
npm install gray-matter # Парсинг frontmatter
npm install reading-time # Время чтения
npm install shiki # Подсветка кода
npm install rehype-slug rehype-autolink-headings # Якоря для заголовков
npm install rss # Генерация RSSТипы и утилиты
Типы статьи
// lib/types.ts
export interface ArticleFrontmatter {
title: string;
excerpt: string;
date: string;
tags: string[];
category: string;
difficulty: 'beginner' | 'intermediate' | 'advanced';
featured?: boolean;
}
export interface Article {
slug: string;
frontmatter: ArticleFrontmatter;
content: string;
readTime: number;
}
export interface ArticlePreview {
slug: string;
title: string;
excerpt: string;
date: string;
tags: string[];
category: string;
difficulty: string;
readTime: number;
}Загрузка статей
// lib/articles.ts
import fs from 'fs';
import path from 'path';
import matter from 'gray-matter';
import readingTime from 'reading-time';
import type { Article, ArticlePreview, ArticleFrontmatter } from './types';
const ARTICLES_DIR = path.join(process.cwd(), 'content/articles');
export function getArticleSlugs(): string[] {
return fs
.readdirSync(ARTICLES_DIR)
.filter((file) => file.endsWith('.mdx'))
.map((file) => file.replace(/\.mdx$/, ''));
}
export function getArticleBySlug(slug: string): Article | null {
const filePath = path.join(ARTICLES_DIR, `${slug}.mdx`);
if (!fs.existsSync(filePath)) {
return null;
}
const fileContent = fs.readFileSync(filePath, 'utf-8');
const { data, content } = matter(fileContent);
const { minutes } = readingTime(content);
return {
slug,
frontmatter: data as ArticleFrontmatter,
content,
readTime: Math.ceil(minutes),
};
}
export function getAllArticles(): ArticlePreview[] {
const slugs = getArticleSlugs();
const articles = slugs
.map((slug) => {
const article = getArticleBySlug(slug);
if (!article) return null;
return {
slug,
title: article.frontmatter.title,
excerpt: article.frontmatter.excerpt,
date: article.frontmatter.date,
tags: article.frontmatter.tags,
category: article.frontmatter.category,
difficulty: article.frontmatter.difficulty,
readTime: article.readTime,
};
})
.filter((article): article is ArticlePreview => article !== null);
// Сортировка по дате (новые первыми)
return articles.sort(
(a, b) => new Date(b.date).getTime() - new Date(a.date).getTime()
);
}
export function getArticlesByCategory(category: string): ArticlePreview[] {
return getAllArticles().filter((a) => a.category === category);
}
export function getAllCategories(): string[] {
const articles = getAllArticles();
const categories = new Set(articles.map((a) => a.category));
return Array.from(categories);
}Рендеринг MDX
Настройка MDX-процессора
// lib/mdx.ts
import { compileMDX } from 'next-mdx-remote/rsc';
import rehypeSlug from 'rehype-slug';
import rehypeAutolinkHeadings from 'rehype-autolink-headings';
import { mdxComponents } from '@/components/mdx';
import { codeHighlighter } from './shiki';
export async function renderMDX(source: string) {
const { content, frontmatter } = await compileMDX({
source,
components: mdxComponents,
options: {
parseFrontmatter: true,
mdxOptions: {
rehypePlugins: [
rehypeSlug,
[
rehypeAutolinkHeadings,
{
behavior: 'wrap',
properties: { className: ['anchor'] },
},
],
codeHighlighter,
],
},
},
});
return { content, frontmatter };
}Подсветка кода с Shiki
// lib/shiki.ts
import { createHighlighter, type Highlighter } from 'shiki';
import { visit } from 'unist-util-visit';
let highlighter: Highlighter;
async function getHighlighter() {
if (!highlighter) {
highlighter = await createHighlighter({
themes: ['github-dark'],
langs: [
'typescript',
'javascript',
'tsx',
'jsx',
'php',
'bash',
'json',
'yaml',
'sql',
'css',
'html',
],
});
}
return highlighter;
}
export function codeHighlighter() {
return async (tree: any) => {
const highlighter = await getHighlighter();
visit(tree, 'element', (node) => {
if (
node.tagName === 'pre' &&
node.children?.[0]?.tagName === 'code'
) {
const codeNode = node.children[0];
const code = codeNode.children?.[0]?.value || '';
const lang = codeNode.properties?.className?.[0]?.replace(
'language-',
''
) || 'text';
const html = highlighter.codeToHtml(code, {
lang,
theme: 'github-dark',
});
node.type = 'raw';
node.value = html;
}
});
};
}MDX-компоненты
InfoBox и WarningBox
// components/mdx/InfoBox.tsx
interface InfoBoxProps {
children: React.ReactNode;
}
export function InfoBox({ children }: InfoBoxProps) {
return (
<div className="info-box">
<div className="info-box-icon">💡</div>
<div className="info-box-content">{children}</div>
</div>
);
}
export function WarningBox({ children }: InfoBoxProps) {
return (
<div className="warning-box">
<div className="warning-box-icon">⚠️</div>
<div className="warning-box-content">{children}</div>
</div>
);
}Экспорт компонентов
// components/mdx/index.tsx
import { InfoBox, WarningBox } from './InfoBox';
import { CodeBlock } from './CodeBlock';
import Image from 'next/image';
import Link from 'next/link';
export const mdxComponents = {
InfoBox,
WarningBox,
// Кастомные элементы
a: ({ href, children, ...props }: React.AnchorHTMLAttributes<HTMLAnchorElement>) => {
const isExternal = href?.startsWith('http');
if (isExternal) {
return (
<a href={href} target="_blank" rel="noopener noreferrer" {...props}>
{children}
</a>
);
}
return <Link href={href || '#'} {...props}>{children}</Link>;
},
img: ({ src, alt, ...props }: React.ImgHTMLAttributes<HTMLImageElement>) => (
<Image
src={src || ''}
alt={alt || ''}
width={800}
height={400}
className="rounded-lg"
{...props}
/>
),
pre: ({ children, ...props }: React.HTMLAttributes<HTMLPreElement>) => (
<div className="code-block-wrapper">
<pre {...props}>{children}</pre>
</div>
),
};Страницы блога
Список статей
// app/blog/page.tsx
import { getAllArticles, getAllCategories } from '@/lib/articles';
import { ArticleCard } from '@/components/ArticleCard';
import { CategoryFilter } from '@/components/CategoryFilter';
interface BlogPageProps {
searchParams: Promise<{ category?: string; page?: string }>;
}
export default async function BlogPage({ searchParams }: BlogPageProps) {
const params = await searchParams;
const category = params.category;
const page = parseInt(params.page || '1', 10);
const allArticles = getAllArticles();
const categories = getAllCategories();
const filteredArticles = category
? allArticles.filter((a) => a.category === category)
: allArticles;
// Пагинация
const perPage = 12;
const totalPages = Math.ceil(filteredArticles.length / perPage);
const articles = filteredArticles.slice(
(page - 1) * perPage,
page * perPage
);
return (
<main className="container py-12">
<h1 className="text-4xl font-bold mb-8">Блог</h1>
<CategoryFilter
categories={categories}
current={category}
/>
<div className="grid md:grid-cols-2 lg:grid-cols-3 gap-6 mt-8">
{articles.map((article) => (
<ArticleCard key={article.slug} article={article} />
))}
</div>
{totalPages > 1 && (
<Pagination current={page} total={totalPages} />
)}
</main>
);
}Страница статьи
// app/blog/[slug]/page.tsx
import { notFound } from 'next/navigation';
import { getArticleBySlug, getArticleSlugs } from '@/lib/articles';
import { renderMDX } from '@/lib/mdx';
import { TableOfContents } from '@/components/TableOfContents';
import { ArticleHeader } from '@/components/ArticleHeader';
export async function generateStaticParams() {
return getArticleSlugs().map((slug) => ({ slug }));
}
export async function generateMetadata({
params,
}: {
params: Promise<{ slug: string }>;
}) {
const { slug } = await params;
const article = getArticleBySlug(slug);
if (!article) return { title: 'Статья не найдена' };
return {
title: article.frontmatter.title,
description: article.frontmatter.excerpt,
openGraph: {
title: article.frontmatter.title,
description: article.frontmatter.excerpt,
type: 'article',
publishedTime: article.frontmatter.date,
},
};
}
export default async function ArticlePage({
params,
}: {
params: Promise<{ slug: string }>;
}) {
const { slug } = await params;
const article = getArticleBySlug(slug);
if (!article) {
notFound();
}
const { content } = await renderMDX(article.content);
return (
<div className="article-layout">
<article className="article-main">
<ArticleHeader
title={article.frontmatter.title}
date={article.frontmatter.date}
readTime={article.readTime}
tags={article.frontmatter.tags}
/>
<div className="article-content prose">
{content}
</div>
</article>
<aside className="article-sidebar">
<TableOfContents />
</aside>
</div>
);
}Оглавление (Table of Contents)
// components/TableOfContents.tsx
'use client';
import { useEffect, useState } from 'react';
interface Heading {
id: string;
text: string;
level: number;
}
export function TableOfContents() {
const [headings, setHeadings] = useState<Heading[]>([]);
const [activeId, setActiveId] = useState<string>('');
useEffect(() => {
const elements = document.querySelectorAll(
'.article-content h2, .article-content h3'
);
const items: Heading[] = Array.from(elements).map((el) => ({
id: el.id,
text: el.textContent || '',
level: el.tagName === 'H2' ? 2 : 3,
}));
setHeadings(items);
}, []);
useEffect(() => {
const observer = new IntersectionObserver(
(entries) => {
entries.forEach((entry) => {
if (entry.isIntersecting) {
setActiveId(entry.target.id);
}
});
},
{ rootMargin: '-100px 0px -80% 0px' }
);
headings.forEach((heading) => {
const el = document.getElementById(heading.id);
if (el) observer.observe(el);
});
return () => observer.disconnect();
}, [headings]);
if (headings.length === 0) return null;
return (
<nav className="toc">
<h4 className="toc-title">Содержание</h4>
<ul className="toc-list">
{headings.map((heading) => (
<li
key={heading.id}
className={`toc-item ${heading.level === 3 ? 'toc-item--nested' : ''}`}
>
<a
href={`#${heading.id}`}
className={activeId === heading.id ? 'active' : ''}
onClick={(e) => {
e.preventDefault();
document.getElementById(heading.id)?.scrollIntoView({
behavior: 'smooth',
});
}}
>
{heading.text}
</a>
</li>
))}
</ul>
</nav>
);
}RSS-лента
// app/feed.xml/route.ts
import { getAllArticles } from '@/lib/articles';
import RSS from 'rss';
export async function GET() {
const articles = getAllArticles();
const feed = new RSS({
title: 'Блог Дмитрия Мещерякова',
description: 'Статьи о веб-разработке',
site_url: 'https://dmeshcheryakov.ru',
feed_url: 'https://dmeshcheryakov.ru/feed.xml',
language: 'ru',
pubDate: new Date(),
});
articles.slice(0, 20).forEach((article) => {
feed.item({
title: article.title,
description: article.excerpt,
url: `https://dmeshcheryakov.ru/blog/${article.slug}`,
date: new Date(article.date),
categories: article.tags,
});
});
return new Response(feed.xml({ indent: true }), {
headers: {
'Content-Type': 'application/xml',
'Cache-Control': 'max-age=3600, s-maxage=3600',
},
});
}Sitemap
// app/sitemap.ts
import { MetadataRoute } from 'next';
import { getAllArticles } from '@/lib/articles';
export default function sitemap(): MetadataRoute.Sitemap {
const articles = getAllArticles();
const articleUrls = articles.map((article) => ({
url: `https://dmeshcheryakov.ru/blog/${article.slug}`,
lastModified: new Date(article.date),
changeFrequency: 'weekly' as const,
priority: 0.8,
}));
return [
{
url: 'https://dmeshcheryakov.ru',
lastModified: new Date(),
changeFrequency: 'daily',
priority: 1,
},
{
url: 'https://dmeshcheryakov.ru/blog',
lastModified: new Date(),
changeFrequency: 'daily',
priority: 0.9,
},
...articleUrls,
];
}Деплой
Vercel (рекомендуется)
npm i -g vercel
vercelСвой VPS
# Build
npm run build
# PM2
pm2 start npm --name "blog" -- start
pm2 saveЧем платим за MDX
Подход отличный, но у него есть особенности, которые проявляются не сразу.
Ошибка в статье ломает сборку. MDX — это код: незакрытый компонент или случайные фигурные скобки в тексте приводят к ошибке компиляции, и падает не одна страница, а вся сборка. Плюс в том, что проблема ловится до выкладки; минус — писать статьи в таком формате может только тот, кто понимает сообщения об ошибках сборщика.
Фигурные скобки в тексте — отдельная ловушка. MDX трактует {...} как выражение JavaScript, поэтому фраза с шаблоном вида {email} в обычном абзаце уронит сборку с сообщением «email is not defined». Знать об этом нужно заранее: в первый раз причина совершенно не очевидна.
Контент недоступен для правки нетехническими людьми. Git, ветки, MDX-синтаксис — это порог, который не преодолеет редактор или маркетолог. Для личного блога это скорее плюс (никакой админки и базы данных), для проекта с редакцией — блокирующее ограничение, и решать его придётся отдельной CMS.
Время сборки растёт линейно с числом статей. Каждая страница генерируется статически, и на паре сотен статей это уже ощутимо в CI. Подсветка кода — самая дорогая часть; если сборка станет проблемой, начинать оптимизацию нужно с неё.
Итоги
Что стоит забрать из этого разбора:
MDX хорош, когда автор — разработчик. Статьи в Git, ревью через pull request, история изменений, никакой базы и админки. Для технического блога это лучшее соотношение простоты и возможностей.
Держите набор компонентов маленьким. Два-три собственных компонента (InfoBox, WarningBox) покрывают почти всё. Каждый новый компонент — это то, что нужно помнить при написании статьи, и то, что придётся поддерживать при смене вёрстки.
Frontmatter должен валидироваться. Опечатка в поле даты или пропущенный excerpt не должны обнаруживаться на выкладке — проверка при сборке дешевле.
Сразу заложите проверку ссылок. Битые внутренние ссылки в статьях накапливаются незаметно; простой скрипт, обходящий их при сборке, экономит много неловкости.
Полный код этого блога: github.com/dmeshcheryakov/blog
Частые вопросы
Почему для блога стоит выбрать MDX?
Потому что это Markdown с поддержкой React-компонентов: текст пишется как обычно, но в него можно вставлять интерактивные блоки, frontmatter типизируется через TypeScript, а статьи версионируются вместе с кодом в Git. Для технического блога, где автор сам разработчик, это лучшее соотношение простоты и возможностей: ревью статей идёт через pull request, а базы данных и админки нет вообще.
Почему сборка MDX падает с ошибкой «email is not defined»?
Потому что MDX трактует фигурные скобки как выражение JavaScript. Шаблон вида {email} в обычном абзаце воспринимается как обращение к несуществующей переменной и роняет сборку. В первый раз причина совершенно не очевидна, поэтому про это стоит знать заранее: фигурные скобки в тексте нужно экранировать или оборачивать в код.
Что происходит при ошибке в одной MDX-статье?
Падает вся сборка, а не одна страница: MDX — это код, и незакрытый компонент или случайные фигурные скобки приводят к ошибке компиляции. Плюс в том, что проблема ловится до выкладки; минус — писать статьи в таком формате может только тот, кто понимает сообщения об ошибках сборщика.
Кому MDX-блог не подойдёт?
Проектам с редакцией. Git, ветки и MDX-синтаксис — это порог, который не преодолеет редактор или маркетолог. Для личного технического блога такое ограничение скорее плюс: никакой админки и базы данных. Для проекта, где контент правят нетехнические люди, оно блокирующее, и решать его придётся отдельной CMS.
Как быстро растёт время сборки MDX-блога?
Линейно с числом статей: каждая страница генерируется статически, и на паре сотен статей это уже ощутимо в CI. Самая дорогая часть — подсветка кода через Shiki, поэтому если сборка станет проблемой, оптимизацию нужно начинать именно с неё.
Что заложить в MDX-блог с самого начала?
Валидацию frontmatter при сборке: опечатка в поле даты или пропущенный excerpt не должны обнаруживаться на выкладке. Проверку внутренних ссылок — битые ссылки накапливаются незаметно, а простой скрипт при сборке экономит много неловкости. И держите набор собственных компонентов маленьким: двух-трёх вроде InfoBox и WarningBox хватает почти на всё, а каждый новый — это то, что нужно помнить при написании статьи и поддерживать при смене вёрстки.