2023 年 Next.js 内容站选 Contentlayer 是显然之选。后来它失去维护者,App Router 更新跟不上,社区分叉。2026 年新项目的实际问题是:MDX bundler 还是 next-mdx-remote,配套工具怎么搭。本文按真正重要的点比较两条路。
问题背景
两条路要干的事一样:读磁盘上 .mdx 文件、解析 frontmatter、把 MDX 编译成 React 组件、在 App Router 页面里渲染。Contentlayer 多了 build 时 schema 校验和从 frontmatter 生成 TypeScript 类型——这正是大家怀念的 DX。MDX bundler 和 next-mdx-remote 负责编译,schema(一般 Zod)和类型生成要自己来。
判断标准
- 2023 年用 Contentlayer 开了 Next.js 内容站,现在 CI 在 Next.js 15 上挂了。
- 想要 frontmatter 类型安全,又不想绑死一个 CMS。
- 文章里用了自定义 MDX 组件(Callout、CodeBlock、Diagram),希望集中注册一次。
- 文章数百上千,构建时间敏感。
快速结论
2026 年新项目,用 next-mdx-remote/rsc 配 Zod 校验 frontmatter。在 lib/content.ts 里搭一层薄封装,把 Contentlayer 给的能力补回来。除非确实需要 MDX bundler 的隔离特性,否则跳过。除非有活跃的 fork 已经稳定,否则跳过 Contentlayer——上车前先看仓库活跃度。
App Router 用 next-mdx-remote
最直接的路径。App Router 的 server component 能在服务端编译 MDX,客户端零 bundle 成本:
// app/articles/[slug]/page.tsx
import { compileMDX } from 'next-mdx-remote/rsc';
import { readFile } from 'node:fs/promises';
import path from 'node:path';
import { z } from 'zod';
import { mdxComponents } from '@/components/mdx';
const FrontmatterSchema = z.object({
title: z.string(),
description: z.string(),
publishedAt: z.coerce.date(),
tags: z.array(z.string()),
});
export default async function ArticlePage({ params }: { params: { slug: string } }) {
const raw = await readFile(
path.join(process.cwd(), 'content', `${params.slug}.mdx`),
'utf8',
);
const { content, frontmatter } = await compileMDX({
source: raw,
components: mdxComponents,
options: { parseFrontmatter: true },
});
const meta = FrontmatterSchema.parse(frontmatter);
return (
<article>
<h1>{meta.title}</h1>
<time>{meta.publishedAt.toISOString()}</time>
{content}
</article>
);
}
generateStaticParams 读内容目录,generateMetadata 复用同一个 schema。整条 pipeline 大约 80 行自己掌控的代码。
需要隔离时用 MDX bundler
MDX bundler(Kent C. Dodds 的 mdx-bundler)把 MDX 编译成自包含的 JS 字符串,运行时再 eval。它的优势是当 MDX 来自数据库或 CMS、每篇文章可能 import 不同组件时——bundler 把每次编译相互隔离。对文件系统 MDX,这层隔离反而是开销。
// MDX 内容来自远端
import { bundleMDX } from 'mdx-bundler';
const { code, frontmatter } = await bundleMDX({
source: mdxStringFromCMS,
cwd: process.cwd(),
});
文件系统内容站走 next-mdx-remote 更简单。内容在仓库外才用 MDX bundler。
把 Contentlayer 的 DX 补回来
大家舍不得 Contentlayer 的三件事:
- Frontmatter 类型生成。 用 Zod schema +
z.infer<typeof FrontmatterSchema>拿类型,10 行 helper 搞定。 - 计算字段。 Contentlayer 让你从文件名生成
urlSlug。同样的模式:在lib/content.ts写个函数,把原始 frontmatter 映射成更丰富的对象。 - 跨页面缓存解析结果。 Contentlayer 在构建时复用解析后的 MDX。替代方案是按文件 mtime 做内存缓存,或者干脆不做——Next 15 对
fetch和 read 的缓存对大多数站已经够用。
写下来 lib/content.ts 大概 150-200 行,DX 不输 Contentlayer,又不用扛一份没维护的依赖。
容易踩的坑
- 服务端编译时 import 仅限客户端的 MDX 组件,hydration 不匹配——交互组件放
'use client'文件里,通过 components map 传进去。 - 忘了写
generateStaticParams——每篇文章变动态路由,SEO 和 Lighthouse 全掉。 - 在 client component 里跑 MDX 编译——App Router 的意义没了,运行时 bundle 爆。
- 只在运行时校验 frontmatter——schema 错就在生产挂。build 时也跑一次校验,最好接 CI。
- 残留旧的 Contentlayer 依赖、peer-dep 报警——迁完
npm uninstall contentlayer next-contentlayer。
FAQ
- Contentlayer 是不是凉了?: 原始仓库一年多没维护了。有 fork(搜
contentlayer2)但用量小。新项目上车前查活跃度。 - Velite 怎么样?: Velite 是个 build 时的强力替代,能做到 Contentlayer 做的事。想要类型生成 DX 同时仍有人维护,可以评估。
- 直接用官方
@next/mdx行吗?: 简单站行(每个 MDX 就是一个页面,比如app/articles/foo/page.mdx)。一旦需要从内容目录做动态路由就崩。 - next-mdx-remote 支持 remark / rehype 插件吗?: 支持,options 里传
remarkPlugins和rehypePlugins,和 MDX 本身一样用。 - Astro 呢?: Astro 原生支持 MDX,content collections 和 Zod schema 内置——本站就是这么用的。想要 Contentlayer 的 DX 又不想踩 Contentlayer 的坑、又不需要 React 应用壳,Astro 是更轻的路。