搭建这个博客:Next.js 16 上的 MDX、静态化与 SEO

记录我如何为这个作品集添加多语言 MDX 博客:渲染管线、SEO 细节,以及让它保持完全静态的几个关键决策。

约 3 分钟

要点

这个博客就是一堆 MDX 文件,在构建时由 @next/mdx 渲染;元数据走另一条 Node 侧的独立管线。没有 CMS,没有运行时渲染,混合翻译的文章也有诚实的 hreflang。

本页目录

这个网站过去只有一个页面。目标只是"展示作品"时这没什么问题,但我总是遇到同一件事:在工作中解决了有意思的问题,笔记写在私人文档里,然后就再也没发布过。博客能把发布的成本降到"写一个 markdown 文件",所以我搭了一个。

这篇文章讲讲它是怎么工作的。如果你也在给带 next-intl 的 Next.js 16 应用接入 MDX,大部分内容可以直接照搬。

管线设计

内容存放在 content/blog/<locale>/<slug>.mdx。每个文件带 YAML frontmatter——标题、描述、日期、标签——在构建时用 zod 校验。每篇文章会经过两套独立的系统:

  1. 元数据在 Node 侧用 gray-matter 读取,驱动索引页、RSS、sitemap、JSON-LD 和 generateMetadata,完全不经过 MDX 编译器。
  2. 渲染@next/mdx 完成,文章页通过动态 import 加载。编译器永远看不到 frontmatter——remark-frontmatter 会把它剥掉。

把这两件事拆开,是整个搭建过程中最有价值的决定。frontmatter 是数据,MDX 是呈现;试图用一条管线同时服务两者的方案,最后都会和打包器打架。

Turbopack 改变了插件规则

Next.js 16 默认用 Turbopack 构建,这带来一个容易忽略的后果:next.config.ts 里的 remark 和 rehype 插件必须是字符串名称加可 JSON 序列化的选项,因为配置要跨进 Rust 的世界:

ts
const withMDX = createMDX({
  options: {
    remarkPlugins: ["remark-frontmatter", "remark-gfm"],
    rehypePlugins: [
      "rehype-slug",
      ["rehype-pretty-code", { theme: { light: "github-light", dark: "github-dark-dimmed" }, keepBackground: false }],
    ],
  },
});

凡是需要真正 JavaScript 函数的工作——阅读时长、目录、markdown 序列化——都放在 lib/blog.ts 里的 Node 环境中执行,那里没有序列化限制。目录的锚点 id 用 github-slugger 生成,和 rehype-slug 是同一套算法,所以侧边栏的链接能和渲染出来的标题一一对应。

不伪造 URL 的多语言策略

文章会用一到三种语言写作,翻译总是滞后的。诚实的做法是:

URL 状态渲染内容Canonical
该语言存在译文对应语言的内容自身
该语言没有译文源语言内容 + 提示条源语言 URL

回退页面不进 sitemap,也不进 hreflang。搜索引擎只会看到承载真实译文的 URL,而打开回退 URL 的读者也能明确知道自己在读什么。

面向机器的读取面

2026 年的博客,读者里爬虫和大语言模型不比人少,所以每篇文章还会同时输出:

  • 原始 markdown,路径是 /<locale>/blog/<slug>/md,带一小段来源信息头。
  • 按语言拆分的全文 RSS,直接用编译后的 MDX 渲染。
  • JSON-LD(BlogPosting + BreadcrumbList),dateModified 是真实值。
  • 自动生成的 llms.txt 条目。

这些全部在构建时从同一批文件生成,没有第二套需要同步的系统——markdown 一改,下次部署时所有出口一起更新。

会重新考虑的地方

我评估过 headless CMS,最后放弃了:这些文章的读者不多,作者只有我一个,而 git 比任何网页表单都更适合我的写作流程。如果哪天情况变了,lib/schema.ts 里的 zod schema 就是 CMS 需要满足的契约——管线的其他部分不用动。

代码就在这个站点的仓库里,而你正在读的这篇文章,就是它的测试样本。