一、我们到底想解决什么问题
很多个人博客在刚搭建时都很好看,但真正使用几个月后,问题才会出现:写文章必须打开代码编辑器;图片路径难以管理;每次发布都要手动提交 Git;运行时频繁请求内容 API,页面既慢又不稳定;换主题时又发现内容和界面已经耦合在一起。
因此,我们一开始就确定了几个目标:
- 写作体验优先:文章在 Notion 中完成,不要求作者理解 Markdown、Git 或部署流程。
- 访问性能优先:读者访问文章时不直接请求 Notion,正文应当尽可能从已构建的静态资源读取。
- 内容与设计解耦:Notion 管文章,前端代码管理布局、字体、动画、搜索、归档和交互。
- 允许失败:即使 Notion API 临时超时,构建也不能把线上博客变成空站。
- 可以继续生长:以后增加系列文章、友链、评论、浏览量、RSS 或站点地图时,不需要推翻整个架构。
最终选用的技术组合是:
| 层级 | 技术 | 职责 |
|---|---|---|
| 内容后台 | Notion Database + Notion API | 编辑文章、维护元数据、控制发布状态 |
| 前端 | Next.js 16、React 19、vinext、Vite | 页面、路由、渲染、交互与设计系统 |
| 内容构建 | Node.js 同步脚本 | 查询 Notion、递归拉取区块、下载图片并生成快照 |
| 部署 | Netlify | 构建、CDN、Functions、定时任务与自定义域名 |
| 评论 | Giscus + GitHub Discussions | 无自建数据库的文章评论系统 |
二、核心架构:Notion 是后台,但不是运行时数据库
这是整套方案中最重要的决定。
最直接的做法,是每位读者打开文章时都由服务器请求 Notion API,再把返回内容转成 HTML。它开发简单,但会带来三个问题:Notion API 延迟会直接变成页面延迟;访问量增加后容易碰到限流;Notion 临时不可用时,博客也会一起不可用。
我们采用的是 构建时同步 + 全静态快照:
作者在 Notion 写作
│
▼
状态改为 Published
│
▼
Notion Webhook → Netlify Build Hook
│
▼
scripts/sync-notion.mjs
├─ 查询已发布文章
├─ 递归读取正文区块
├─ 下载并本地化图片
└─ 生成 notion-snapshot.json
│
▼
Next.js / vinext 构建页面
│
▼
Netlify CDN 向读者提供结果这样,Notion 只存在于“内容生产和构建”阶段。读者访问首页、归档、分类、RSS 或文章页时,读取的是随部署产生的快照,而不是 Notion API。
三、建立 Notion 内容数据库
3.1 创建 Integration
在 Notion 的 Connections 页面创建一个 Internal Integration,并获得 Integration Token。这个 Token 相当于程序访问 Notion 的钥匙,不能提交到 GitHub,也不能写进前端代码。
创建完成后,需要在博客数据库页面中打开连接设置,把该 Integration 添加到数据库。仅仅“创建 Integration”并不代表它能读取你的页面;数据库必须显式共享给它。
3.2 设计数据库字段
当前博客使用以下字段:
| 字段 | 类型 | 作用 |
|---|---|---|
| title | Title | 文章标题 |
| slug | Rich text | 文章 URL,例如 build-notion-blog-from-scratch |
| status | Select | Draft、Invisible 或 Published |
| type | Select | 文章必须设为 Post |
| summary | Rich text | 首页卡片和 SEO 摘要 |
| date | Date | 发布日期 |
| category | Select | 文章主分类 |
| tags | Multi-select | 用于主题聚合和相关文章 |
| featured | Checkbox | 是否在首页重点展示 |
| Series | Select | 系列文章名称 |
| Series Order | Number | 系列内部顺序 |
同步脚本只接受同时满足以下条件的页面:
status === "Published"
type === "Post"
title 非空
slug 非空这里最容易踩坑的是字段名和选项值。Notion API 返回的属性名称、大小写、Select 文本必须与代码约定一致。页面在 Notion 中看起来“已经发布”,但如果状态实际写成 published、类型不是 Post,或缺少 slug,同步脚本仍会主动忽略它。
四、编写构建时同步器
核心脚本位于 scripts/sync-notion.mjs。它的任务并不是简单请求一次数据库,而是把 Notion 中分散的页面属性、嵌套区块和临时资源整理成前端能稳定消费的数据。
4.1 查询数据源
新版 Notion API 使用 Data Source 查询接口:
const response = await fetch(
`https://api.notion.com/v1/data_sources/${dataSourceId}/query`,
{
method: "POST",
headers: {
Authorization: `Bearer ${token}`,
"Notion-Version": "2026-03-11",
"Content-Type": "application/json",
},
body: JSON.stringify({ page_size: 100 }),
},
);实际实现还处理了分页,并为 429 和 5xx 响应加入指数退避重试。生产环境中不能假设一次请求必然成功。
4.2 映射页面属性
数据库查询结果只包含页面属性,不包含完整正文。我们先把元数据整理成统一的 Post 结构:
function mapPage(page) {
const title = richText(property(page.properties, "title"));
const slug = richText(property(page.properties, "slug"));
const status = selectValue(property(page.properties, "status"));
const type = selectValue(property(page.properties, "type"));
if (!title || !slug || status !== "Published" || type !== "Post") {
return null;
}
return {
id: page.id,
title,
slug,
description: richText(property(page.properties, "summary")),
publishedAt: property(page.properties, "date")?.date?.start,
};
}property() 会做不区分大小写的后备查找,这能减少数据库字段大小写轻微变化造成的问题,但发布规范仍然应尽量固定。
4.3 递归读取正文区块
Notion 页面正文是一棵树。列表项、Toggle、Callout、Column、Synced Block 都可能包含子区块,因此只请求页面第一层会丢失大量内容。
我们的同步器会:
- 分页请求
/blocks/{blockId}/children。 - 检查每个区块的
has_children。 - 以有限并发递归展开子树。
- 删除前端不需要的冗余字段,保存紧凑区块结构。
并发数被限制为 3,目的是在构建速度和 Notion API 限流之间取得平衡。
4.4 把 Notion 图片变成本地静态资源
Notion 文件 URL 往往带有过期签名。如果直接把它写入文章,几小时或几天后图片可能失效。
同步阶段会下载图片,校验 Content-Type 和体积,再以稳定哈希命名保存到 public/notion/:
public/notion/{block-id}-{sha256摘要}.webp正文中的远程地址随后被替换成站内地址。这样图片跟随部署进入 CDN,不再依赖临时链接,也减少了读者访问 Notion/S3 的额外网络请求。
4.5 生成快照
同步完成后会产生三个关键结果:
content/notion-snapshot.json
public/notion/
public/notion-snapshot-meta.json第一份包含文章元数据和正文区块;第二个目录保存本地化图片;第三份只保存生成时间、最近编辑时间和文章数量,供线上新鲜度检查使用。
五、失败时不要发布“空博客”
构建系统必须把“无法获得新内容”和“确认内容为空”区分开。
如果 Notion 请求失败,而仓库中已经有上一次成功快照,我们会保留旧内容并继续构建:
try {
const posts = await queryPosts();
// 拉取正文并写入新快照
} catch (error) {
const snapshot = await existingSnapshot();
if (!snapshot.posts?.length) throw error;
console.warn("refresh failed; preserving the last good snapshot");
}这个策略非常重要。它意味着一次 Notion 超时最多让新文章晚一点上线,而不会让现有文章全部消失。
六、在前端还原 Notion 内容
同步器保存原始区块语义,前端组件 NotionContent.tsx 再把它们映射成 React 元素。当前支持:
- 段落、三级标题、引用和分割线;
- 有序列表、无序列表、嵌套列表和待办事项;
- Callout、Toggle、Column 和 Synced Block;
- 表格、图片、视频、音频、PDF 和文件;
- 代码块、语法高亮与复制按钮;
- 行内公式、块级公式和 KaTeX;
- 粗体、斜体、删除线、下划线、行内代码、颜色与链接。
6.1 为什么不能把所有内容先转成普通 Markdown
普通 Markdown 无法完整表达 Notion 的列布局、Callout 图标、Toggle 子树、表格表头、颜色和附件。过早转成 Markdown 会丢失结构,之后再想恢复就很困难。
保留 Notion Block Tree,再逐类渲染,代码虽然多一些,但扩展能力更强。
6.2 换行、列表和数学公式
这类细节最容易出现“Notion 里正常,网页上却不正常”的情况。
- Rich Text 内部的换行需要显式转换成
。 - 连续的 list item 必须先分组,再包进同一个
<ul>或<ol>。 - Notion equation 对象要交给 KaTeX,而不是当普通字符串输出。
- 行内代码和粗斜体必须按照 annotations 的组合顺序嵌套。
公式渲染示例:
const html = katex.renderToString(expression, {
displayMode: true,
throwOnError: false,
output: "htmlAndMathml",
});七、用 Netlify 构建并托管
项目的构建命令是:
npm run build:netlify它先执行 Notion 同步,再执行 Vite/vinext 构建:
{
"scripts": {
"sync:notion": "node scripts/sync-notion.mjs",
"build:netlify": "node scripts/sync-notion.mjs && vite build"
}
}netlify.toml 中指定发布目录:
[build]
command = "npm run build:netlify"
publish = "dist"
[build.environment]
NODE_VERSION = "22.14.0"
NITRO_PRESET = "netlify"需要在 Netlify 项目环境变量中设置:
NOTION_TOKEN=你的_Notion_Integration_Token
NOTION_ZH_DATA_SOURCE_ID=你的_Data_Source_ID
NEXT_PUBLIC_SITE_URL=https://你的域名不要把 NOTION_TOKEN 放在 NEXT_PUBLIC_ 前缀下。任何带这个前缀的变量都可能被编译进浏览器资源。
八、让 Notion 修改后自动上线
仅仅在构建时同步还不够。我们希望在 Notion 中把状态改为 Published 后,博客自动重新构建。
8.1 Notion Webhook
Netlify Function 暴露以下入口:
https://kirawii.cn/api/notion-webhookNotion Webhook 发送事件后,函数先使用 NOTION_WEBHOOK_SECRET 验证 x-notion-signature,然后只接受白名单中的页面事件:
page.created
page.content_updated
page.properties_updated
page.deleted
page.undeleted通过验证后,函数调用 NETLIFY_BUILD_HOOK 触发新构建。
必须验证签名。否则任何知道函数地址的人都能不断触发构建,浪费构建额度,甚至构成拒绝服务。
8.2 为什么还需要定时补偿
Webhook 并非绝对可靠:连接配置可能变更,网络请求可能丢失,事件也可能在维护期间失败。因此我们增加了每 30 分钟执行一次的 Netlify Scheduled Function。
它不会无脑重建,而是比较两个值:
- Notion 中最近一篇 Published Post 的
last_edited_time; - 线上
/notion-snapshot-meta.json记录的latestEditedAt。
只有两者不同,才调用 Build Hook。Webhook 负责低延迟,定时检查负责最终一致性。
九、评论系统:Giscus 与 GitHub Discussions
评论系统使用 Giscus。评论不是存进 Notion,也不需要自建数据库,而是存放在公开 GitHub 仓库的 Discussions 中。
配置流程如下:
- 创建公开仓库,例如
Kirawii/kirawii-comments。 - 在仓库 Settings 中开启 Discussions。
- 安装 Giscus GitHub App,并只授权评论仓库。
- 在 giscus.app 选择仓库和
Announcements分类。 - 将生成的仓库 ID 与分类 ID 写入 Netlify 环境变量。
NEXT_PUBLIC_GISCUS_REPO=GitHub用户名/评论仓库
NEXT_PUBLIC_GISCUS_REPO_ID=仓库节点ID
NEXT_PUBLIC_GISCUS_CATEGORY=Announcements
NEXT_PUBLIC_GISCUS_CATEGORY_ID=分类节点ID文章路径使用 pathname 映射,因此每篇文章会对应一个独立 Discussion。脚本还设置了 data-loading="lazy",只有读者接近文章底部时才加载评论 iframe,避免第三方脚本占用首屏资源。
十、性能优化:像产品一样设计加载路径
博客的性能问题通常不是“React 太慢”,而是加载顺序不合理。
当前方案采取了这些措施:
- 正文构建时同步,线上访问不等待 Notion。
- Notion 图片下载到站内,由 CDN 分发。
- 文章图片使用
loading="lazy"与异步解码。 - 评论 iframe 懒加载。
- 页面切换只保留必要的过渡动画,避免大面积滤镜和布局抖动。
- 归档、分类、RSS、站点地图复用同一快照,不重复请求内容源。
- 对动画尊重
prefers-reduced-motion,兼顾可访问性和低性能设备。
一个很实用的原则是:动画应该解释状态变化,而不是延迟状态变化。 页面切换动画可以帮助用户理解“我从哪里来到哪里”,但不能让链接点击后的响应显得迟钝。
十一、本地开发与发布
安装依赖并启动开发环境:
npm install
npm run dev本地同步 Notion:
npm run sync:notion提交前检查:
npm run lint
npm test如果本机没有 Notion 凭据,同步脚本会继续使用仓库中的最后成功快照,方便前端开发者离线调整界面。
手动发布到 Netlify:
npx netlify deploy --prod --build日常使用时则不需要执行这些命令:在 Notion 完成写作,填写元数据,将 type 设为 Post、status 设为 Published,Webhook 会自动完成后续流程。
十二、常见故障排查
12.1 Published 文章没有出现
依次检查:
status是否精确为Published;type是否为Post;title和slug是否非空;- Integration 是否已经连接到数据库;
- Netlify 的
NOTION_TOKEN和 Data Source ID 是否配置; - Netlify 是否已经完成新部署,而不只是 GitHub 收到提交。
12.2 Notion 内容更新很慢
检查 Webhook 日志和 Build Hook;再查看定时 freshness function 是否能读取线上 notion-snapshot-meta.json。如果 Webhook 失效,定时任务最多应在 30 分钟内补偿。
12.3 图片过一段时间失效
说明前端仍在使用 Notion 的临时文件 URL。应在构建阶段下载图片并替换为 /notion/... 的站内路径。
12.4 公式显示成普通文本
确认内容在 Notion 中使用 Equation,而不是在普通段落里手写 $...$。同步后的 rich text 应包含 type: "equation" 和 equation.expression。
12.5 评论区不显示
检查仓库是否公开、Discussions 是否开启、Giscus App 是否获得仓库权限,以及四个 NEXT_PUBLIC_GISCUS_* 变量是否都存在。修改前端公开环境变量后必须重新构建,它们不会自动注入已经生成的 JavaScript。
十三、这套架构适合谁
它尤其适合以下情况:
- 习惯在 Notion 中整理知识和写长文;
- 希望博客拥有完全自定义的视觉和交互,而不是受 Notion 模板限制;
- 不想维护内容数据库和长期运行的服务器;
- 重视访问速度、SEO、归档、RSS 和内容所有权;
- 愿意接受“内容修改后经过一次构建才上线”的发布模型。
如果业务需要文章修改后秒级生效、复杂权限、多作者审核或高频动态数据,可以进一步引入增量构建、对象存储、独立 CMS 或服务端缓存。但对于个人博客,构建时同步通常拥有更好的成本、稳定性和维护体验。
结语
从零搭建这套博客的过程,本质上并不是把 Notion 页面“套一个网页壳”,而是在内容生产、前端表达和线上分发之间建立清晰边界:
- Notion 让写作足够轻;
- 同步快照让内容足够稳;
- 前端设计让表达足够完整;
- Netlify 与 CDN 让访问足够快;
- Webhook、定时补偿和旧快照降级让系统能够长期运行。
最理想的个人博客不是功能最多的博客,而是一套你愿意持续使用、几年后仍然容易理解和维护的系统。对我们而言,这正是选择 Notion + 构建时同步 + 静态部署的原因。