为什么我抛弃了 Sanity CMS,转而使用 MDX(从此不再回头)

发布: (2025年12月31日 GMT+8 17:07)
11 min read
原文: Dev.to

Source: Dev.to

请提供您希望翻译的具体文本内容,我会按照要求将其翻译成简体中文并保留原始的格式、Markdown 语法以及技术术语。谢谢!

设置:我最初为何选择 Sanity

当我第一次构建这个作品集时,Sanity CMS 似乎是管理博客内容的显而易见的选择:

  • 可视化编辑器 – 一个友好的所见即所得写作界面
  • 结构化内容 – 基于 schema 的内容建模
  • 实时协作 – 虽然我只有一个作者 😅
  • CDN 托管的图片 – 自动图片优化
  • Webhook 重新验证 – 内容更改时按需 ISR

它运行良好。但随着时间推移,问题开始显现。

临界点:我为何决定离开

1. 单作者的额外负担

我在为……自己运行整套 CMS 基础设施。Sanity Studio 增加了路由、依赖和复杂度,感觉越来越没有必要:

src/sanity/
├── env.ts
├── lib/
│   ├── client.ts
│   ├── image.ts
│   └── queries.ts
├── schemaTypes/
│   ├── authorType.ts
│   ├── blockContentType.ts
│   ├── categoryType.ts
│   └── postType.ts
└── structure.ts

所有这些基础设施,只是为了一个可以用普通 markdown 文件实现的需求。

2. 外部依赖问题

每次想写作时,我都必须:

  1. 打开我的站点
  2. 访问 /studio
  3. 等待 Sanity Studio 加载
  4. 在它们的编辑器里写作
  5. 希望 webhook 能正确触发以进行重新验证

我的内容托管在别人的服务器上。如果 Sanity 调整价格、出现宕机,或下线某个功能,我就会手忙脚乱。

3. 代码块很麻烦

作为一名编写技术内容的开发者,代码块是必不可少的。Sanity 的 Portable Text 格式需要自定义序列化器,而让语法高亮正常工作总是个挑战:

// Old Sanity code block serializer – verbose and fragile
const CodeBlock = ({ value }: { value: CodeBlockValue }) => {
  return (
    <pre>
      <code>{value.code}</code>
    </pre>
  );
};

使用 MDX,则只需要……markdown:

```typescript
const greeting = "Hello, World!";
```

4. 版本控制?哪来的版本控制?

我的代码在 Git 中。我的内容在 Sanity 中。两个真相来源,零统一历史。我无法轻松地:

  • 在 PR 中审查内容变更
  • 将文章回滚到之前的版本
  • 查看内容变更与代码变更的对应关系

解决方案:使用 Next.js 的 MDX

MDX 为你提供了两全其美的体验:Markdown 的简洁与 React 的强大。下面是我的搭建步骤。

第 1 步 – 安装依赖

pnpm add @next/mdx @mdx-js/loader @mdx-js/react
pnpm add remark-gfm rehype-slug rehype-prism-plus
  • @next/mdx – 官方的 Next.js MDX 集成
  • remark-gfm – GitHub 风格的 Markdown(表格、删除线等)
  • rehype-slug – 为标题自动生成 ID(用于目录)
  • rehype-prism-plus – 使用 Prism.js 进行语法高亮

第 2 步 – 配置 Next.js

// next.config.mjs
import createMDX from '@next/mdx';
import remarkGfm from 'remark-gfm';
import rehypeSlug from 'rehype-slug';
import rehypePrismPlus from 'rehype-prism-plus';

const nextConfig = {
  pageExtensions: ['js', 'jsx', 'md', 'mdx', 'ts', 'tsx'],
  // …other config
};

const withMDX = createMDX({
  options: {
    remarkPlugins: [remarkGfm],
    rehypePlugins: [rehypeSlug, [rehypePrismPlus, { ignoreMissing: true }]],
  },
});

export default withMDX(nextConfig);

第 3 步 – 创建 MDX 组件

项目根目录下的 mdx-components.tsx 文件用于自定义 MDX 元素的渲染方式:

// mdx-components.tsx
import type { MDXComponents } from 'mdx/types';
import Image from 'next/image';
import Link from 'next/link';

export function useMDXComponents(components: MDXComponents): MDXComponents {
  return {
    // 自动生成 ID 的自定义标题
    h2: ({ children, id }) => (
      <h2 id={id}>## {children}</h2>
    ),

    // 智能链接:内部链接 vs 外部链接
    a: ({ href, children }) => {
      if (href?.startsWith('/')) {
        return <Link href={href}>{children}</Link>;
      }
      return (
        <a href={href} target="_blank" rel="noopener noreferrer">
          {children}
        </a>
      );
    },

    // 可访问的代码块
    pre: ({ children }) => (
      <pre className="code-block">
        {children}
      </pre>
    ),

    ...components,
  };
}

第 4 步 – 组织内容结构

每篇博客文章现在都是一个简单的 .mdx 文件,并导出元数据:

content/
└── blog/
    ├── my-first-post.mdx
    ├── another-post.mdx
    └── this-post.mdx

典型文件示例:

export const metadata = {
  title: 'My Blog Post',
  slug: 'my-blog-post',
  publishedAt: '2025-01-01',
  categories: ['nextjs', 'react'],
  coverImage: '/images/blog/my-post.avif',
  author: {
    name: 'Akshay Gupta',
    avatar: '/images/blog-author.png',
  },
  excerpt: 'A short description of the post.',
};

TL;DR

为单个作者运行完整的 CMS 增加了不必要的复杂性。
切换到 MDX 给我带来了:

  • 零外部依赖 – 所有内容都在仓库中
  • 原生 Git 历史 – 内容更改是 PR 的一部分
  • 代码更简洁 – 不需要自定义代码块序列化器
  • 快速可靠的构建 – 不会在运行时从远程 API 拉取

如果你在维护个人博客或小型站点,考虑放弃笨重的 CMS,让 MDX 来承担重活。简约才是终极的精致。

介绍

您的 markdown 内容放在这里…

第5步:构建 MDX 实用工具

我创建了一个小型实用库来处理博客操作:

// src/lib/mdx/index.ts
import fs from 'fs';
import path from 'path';

const CONTENT_DIR = path.join(process.cwd(), 'content', 'blog');

export function getBlogSlugs(): string[] {
  const files = fs.readdirSync(CONTENT_DIR);
  return files
    .filter((file) => file.endsWith('.mdx'))
    .map((file) => file.replace(/\.mdx$/, ''));
}

export async function getBlogBySlug(slug: string) {
  const { metadata } = await import(`@/content/blog/${slug}.mdx`);
  const rawContent = fs.readFileSync(
    path.join(CONTENT_DIR, `${slug}.mdx`),
    'utf-8'
  );
  const readingTime = calculateReadingTime(rawContent);

  return { metadata, slug, readingTime };
}

第6步:渲染博客页面

动态路由直接导入并渲染 MDX:

// src/app/blog/[slug]/page.tsx
export default async function BlogPost({ params }: Props) {
  const { slug } = await params;
  const post = await getBlogBySlug(slug);

  // Dynamic import of the MDX content
  const { default: MDXContent } = await import(
    `@/content/blog/${slug}.mdx`
  );

  return (
    <article>
      <h1>{post.metadata.title}</h1>
      <MDXContent />
    </article>
  );
}

export async function generateStaticParams() {
  const slugs = getBlogSlugs();
  return slugs.map((slug) => ({ slug }));
}

迁移概述:有哪些变化

删除的内容(约 12 000 行)

  • 整个 src/sanity/ 目录
  • Sanity Studio 路由(/studio
  • Webhook 重新验证端点
  • Portable Text 序列化器
  • 8 个与 Sanity 相关的 npm 包

新增的内容(约 7 500 行)

  • content/blog/ 中的 7 篇 MDX 博客文章
  • src/lib/mdx/ 中的 MDX 工具
    • 自定义 MDX 组件
    • Prism.js 语法高亮主题
  • public/images/blog/ 中的封面图片

总体结果: 代码行数减少约 4 700 行——代码更少,bug 更少,维护更简洁。

我现在正在享受的好处

  1. 随处编写 – 我最喜欢的 Markdown 编辑器,VS Code、Obsidian,甚至在紧急情况下使用 neovim。无需浏览器。

  2. Git 原生内容 – 每篇文章都受版本控制。我可以查看完整历史,为草稿创建分支,并在 PR 中与代码一起审查内容更改。

  3. 极速构建 – 构建期间没有 API 调用。所有操作都是本地文件系统读取,因此构建速度明显更快。

  4. 真正的所有权 – 内容存放在我的仓库中。没有供应商锁定,没有意外的价格变动,也没有外部依赖。

  5. 更好的代码块 – 使用 Dracula 主题的 Prism.js,自动语言检测,键盘可访问的代码区域。它就是这么好用:

    // Look ma, beautiful syntax highlighting!
    const sum = (a: number, b: number): number => a + b;
  6. 在 Markdown 中使用 React 组件 – 需要自定义提示框?交互式演示?只需导入并使用:

    import { InteractiveDemo } from '@/components/Demo';
    
    {/* Here’s a live demo: */}
    <InteractiveDemo />

Source:

注意事项与解决方案

1. OpenGraph 图片需要 Node.js 运行时

OG 图片生成器使用 fs 读取 MDX 文件,但 Next.js 的图片路由默认使用 Edge 运行时。通过强制使用 Node.js 运行时来解决:

// src/app/blog/[slug]/opengraph-image.tsx
export const runtime = 'nodejs';

2. 阅读时间计算

在 Sanity 中我可以查询计算字段。使用 MDX 时,我从原始内容中计算阅读时间:

export function calculateReadingTime(content: string) {
  const text = content
    .replace(/```[\s\S]*?```/g, '') // Remove code blocks
    .replace(/`[^`]*`/g, '')        // Remove inline code
    .replace(/]*>/g, '');           // Remove HTML

  const words = text.split(/\s+/).filter(Boolean).length;
  const minutes = Math.ceil(words / 200);

  return { text: `${minutes} min read`, minutes, words };
}

3. 目录生成

由于没有来自 Sanity 的结构化 AST,我使用简单的正则表达式提取标题:

export function extractHeadings(content: string) {
  const headingRegex = /^(#{1,4})\s+(.+)$/gm;
  const headings: { id: string; text: string; level: number }[] = [];

  let match;
  while ((match = headingRegex.exec(content)) !== null) {
    const level = match[1].length;
    const text = match[2].trim();
    const id = text.toLowerCase().replace(/\s+/g, '-');
    headings.push({ id, text, level });
  }

  return headings;
}

是否应该切换?

MDX 完美适用于以下情况:

  • 是单独作者或小团队
  • 编写带有代码块的技术内容
  • 希望内容在版本控制中
  • 重视简洁胜于功能丰富
  • 对 markdown 感到熟悉

如果您更适合使用 CMS,请考虑以下情况:

  • 拥有非技术内容编辑者
  • 需要复杂的工作流和审批
  • 需要实时协作
  • 想要可视化编辑体验

结论

从 Sanity 迁移到 MDX 就像清理一个杂乱的衣橱。立刻的好处显而易见:东西更少,空间更大,寻找东西更容易。真正的乐趣来自于每天仅仅…写作的体验。

没有仪表盘。没有加载旋转图标。没有“同步内容”。只有我、我的编辑器和 Markdown——这才是博客应有的方式。

这个完整博客系统的代码已开源于
github.com/gupta-akshay/portfolio-v2。欢迎随意“偷走”。 🚀

“完美的实现不是因为没有更多可添加的东西,而是因为已经没有可以再去除的东西。” – 安托万·德·圣埃克苏佩里

Back to Blog

相关文章

阅读更多 »

请不要使用 HTMX

HTMX 实际上非常好。这篇文章是关于我为什么还没有使用它,尽管大家一直在劝我使用。人们对 HTMX 的评价 当人们谈论……

Web 开发再次变得有趣

抱歉,我需要您提供要翻译的具体文本内容,才能为您进行翻译。请粘贴您想要翻译的摘录或摘要,我会尽快为您翻译成简体中文。