Files
app-261004/content/posts/building-a-blog-with-nextjs.mdx
T
2026-10-08 11:09:37 +08:00

120 lines
4.7 KiB
Plaintext
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
---
title: "用 Next.js App Router 搭建个人博客"
date: "2025-03-08"
summary: "从零搭一个可写、可读、可部署的博客:内容层怎么设计、Server Component 怎么分工、MDX 与代码高亮怎么接。"
tags: ["Next.js", "MDX", "架构"]
draft: false
---
这个博客的第一版目标很克制:**能写、能读、能部署**。没有后台、没有数据库、没有登录。所有的复杂度都收敛到一件事上——把 Markdown 文件变成页面。
## 为什么不用 CMS
第一版最容易犯的错误,是一上来就选一个「以后肯定用得上」的方案。数据库 + 后台管理听起来很美,但它会带来一整套额外的负担:
- 需要维护 schema 与迁移
- 需要处理鉴权、草稿权限、并发编辑
- 需要为内容管理单独做一套 UI
- 本地开发必须先起一个数据库
而写作本身的需求其实非常朴素:打开编辑器,写 Markdown,提交。Git 已经是一个成熟的内容版本管理系统了——它自带历史记录、分支、协作和回滚。
> 用文件系统当数据库,用 Git 当版本管理。第一版不需要更多东西。
## 内容层设计
文章放在 `content/posts/` 目录,文件名即 slug:
```text
content/posts/
├── building-a-blog-with-nextjs.mdx
├── react-server-components.mdx
└── tailwind-v4-design-tokens.mdx
```
每篇文章顶部是一段 YAML frontmatter,声明标题、日期、摘要、标签和草稿状态:
```yaml
---
title: "用 Next.js App Router 搭建个人博客"
date: "2025-03-08"
summary: "从零搭一个可写、可读、可部署的博客。"
tags: ["Next.js", "MDX"]
draft: false
---
```
读取层只有一个职责:**扫描目录 → 解析 frontmatter → 校验 → 排序**。草稿在读取时就被过滤掉,因此它既不会出现在列表里,也无法通过直接访问 URL 被读到。
### 校验放在读取层
内容错误最容易的暴露时机是构建阶段,而不是线上。所以 frontmatter 的校验写在读取函数里:缺少 `title`、`date` 格式不对、`tags` 不是数组,都会直接抛出错误并终止构建。
```ts
const problems: string[] = [];
if (!title) {
problems.push("缺少必填字段 `title`(标题)");
}
if (problems.length > 0) {
throw new PostValidationError(slug, problems);
}
```
这样一来,错误信息会明确指向**哪个文件**、**哪个字段**,而不是在页面上渲染出一个空白标题。
## Server Component 的分工
App Router 的默认是 Server Component。博客恰好是它的理想场景:内容在构建时就已经确定,没有任何需要客户端状态的东西。
分工原则很简单:
1. **数据获取全部在服务端** —— 用 `server-only` 标记读取层,物理上防止它被打进客户端 bundle
2. **交互才下沉到客户端** —— 滚动进度、目录高亮、移动端菜单这类需要 `useState` 的部分单独拆成客户端组件
3. **列表页不传递正文** —— 列表只需要元信息,正文留在详情页按需读取
`server-only` 这个包很小,但价值很大:
```ts
import "server-only";
```
如果哪次重构不小心把内容读取层 `import` 进了客户端组件,构建会立刻失败,而不是把整个 `content/` 目录打进浏览器产物。
## 排序要稳定
按日期倒序是最直观的排序方式,但只按日期排序会有一个隐患:**同一天发布的两篇文章,顺序可能在不同机器上不一致**。
```ts
function byDateDescending(a: PostMeta, b: PostMeta): number {
const diff = new Date(b.date).getTime() - new Date(a.date).getTime();
return diff !== 0 ? diff : a.slug.localeCompare(b.slug);
}
```
用 slug 兜底之后,排序结果就是确定性的,构建产物也不会因为环境不同而漂移。
## 日期时区陷阱
YAML 里的 `2025-03-08` 会被解析成 Date 对象,而这个 Date 默认是本机时区的午夜。如果服务器在 UTC+8 而构建机在 UTC,同一篇文章可能显示成 3 月 7 日。
解决办法是**始终把日期当作 UTC 处理**:
```ts
new Intl.DateTimeFormat("zh-CN", {
year: "numeric",
month: "long",
day: "numeric",
timeZone: "UTC",
}).format(new Date(`${date}T00:00:00Z`));
```
读取时统一转成 `YYYY-MM-DD` 字符串,格式化时显式指定 `timeZone: "UTC"`。日期在整条链路上就不再漂移了。
## 下一步
第一版到这里就足够了。接下来真正值得做的事,是**先写几篇文章**,让真实的使用暴露出真实的需求——那时候再决定要不要加标签页、搜索或者 RSS,会比现在拍脑袋靠谱得多。
内容层已经预留了扩展点:标签聚合、相邻文章导航都基于同一份元信息,加功能不需要改动存储格式。