MDX-блог на Next.js: от идеи до деплоя

Создаём блог с MDX-статьями, подсветкой кода, оглавлением и RSS. Полный гайд с примерами кода.

Дмитрий Мещеряков
Дмитрий Мещеряков
📅 18 сентября 2026 г.📖 10 мин чтения

Этот блог собран ровно так, как описано ниже, — поэтому статья получилась не обзором возможностей, а разбором принятых решений с их последствиями. Часть из них я бы принял так же, часть — нет, и об этом тоже будет сказано.

Почему MDX

MDX — это Markdown с поддержкой React-компонентов. Идеально для технического блога:

  • Markdown — пишем текст как обычно
  • JSX — вставляем интерактивные компоненты
  • Типизация — frontmatter с TypeScript
  • Git — статьи версионируются вместе с кодом
💡 Совет

Этот блог построен именно так. Все статьи — MDX-файлы в репозитории.

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

text
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

Установка зависимостей

bash
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

Типы и утилиты

Типы статьи

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

Загрузка статей

typescript
// 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-процессора

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

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

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

Экспорт компонентов

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

Страницы блога

Список статей

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

Страница статьи

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

tsx
// 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-лента

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

typescript
// 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 (рекомендуется)

bash
npm i -g vercel
vercel

Свой VPS

bash
# 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 хватает почти на всё, а каждый новый — это то, что нужно помнить при написании статьи и поддерживать при смене вёрстки.