搭建这个博客:Next.js 16 上的 MDX、静态化与 SEO
记录我如何为这个作品集添加多语言 MDX 博客:渲染管线、SEO 细节,以及让它保持完全静态的几个关键决策。
要点
这个博客就是一堆 MDX 文件,在构建时由 @next/mdx 渲染;元数据走另一条 Node 侧的独立管线。没有 CMS,没有运行时渲染,混合翻译的文章也有诚实的 hreflang。
本页目录
这个网站过去只有一个页面。目标只是"展示作品"时这没什么问题,但我总是遇到同一件事:在工作中解决了有意思的问题,笔记写在私人文档里,然后就再也没发布过。博客能把发布的成本降到"写一个 markdown 文件",所以我搭了一个。
这篇文章讲讲它是怎么工作的。如果你也在给带 next-intl 的 Next.js 16 应用接入 MDX,大部分内容可以直接照搬。
管线设计
内容存放在 content/blog/<locale>/<slug>.mdx。每个文件带 YAML frontmatter——标题、描述、日期、标签——在构建时用 zod 校验。每篇文章会经过两套独立的系统:
- 元数据在 Node 侧用
gray-matter读取,驱动索引页、RSS、sitemap、JSON-LD 和generateMetadata,完全不经过 MDX 编译器。 - 渲染由
@next/mdx完成,文章页通过动态 import 加载。编译器永远看不到 frontmatter——remark-frontmatter会把它剥掉。
把这两件事拆开,是整个搭建过程中最有价值的决定。frontmatter 是数据,MDX 是呈现;试图用一条管线同时服务两者的方案,最后都会和打包器打架。
Turbopack 改变了插件规则
Next.js 16 默认用 Turbopack 构建,这带来一个容易忽略的后果:next.config.ts 里的 remark 和 rehype 插件必须是字符串名称加可 JSON 序列化的选项,因为配置要跨进 Rust 的世界:
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 需要满足的契约——管线的其他部分不用动。
代码就在这个站点的仓库里,而你正在读的这篇文章,就是它的测试样本。