一、我们到底想解决什么问题

很多个人博客在刚搭建时都很好看,但真正使用几个月后,问题才会出现:写文章必须打开代码编辑器;图片路径难以管理;每次发布都要手动提交 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 临时不可用时,博客也会一起不可用。

我们采用的是 构建时同步 + 全静态快照

Plain text
作者在 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 设计数据库字段

当前博客使用以下字段:

字段类型作用
titleTitle文章标题
slugRich text文章 URL,例如 build-notion-blog-from-scratch
statusSelectDraft、Invisible 或 Published
typeSelect文章必须设为 Post
summaryRich text首页卡片和 SEO 摘要
dateDate发布日期
categorySelect文章主分类
tagsMulti-select用于主题聚合和相关文章
featuredCheckbox是否在首页重点展示
SeriesSelect系列文章名称
Series OrderNumber系列内部顺序

同步脚本只接受同时满足以下条件的页面:

Plain text
status === "Published"
type === "Post"
title 非空
slug 非空

这里最容易踩坑的是字段名和选项值。Notion API 返回的属性名称、大小写、Select 文本必须与代码约定一致。页面在 Notion 中看起来“已经发布”,但如果状态实际写成 published、类型不是 Post,或缺少 slug,同步脚本仍会主动忽略它。

四、编写构建时同步器

核心脚本位于 scripts/sync-notion.mjs。它的任务并不是简单请求一次数据库,而是把 Notion 中分散的页面属性、嵌套区块和临时资源整理成前端能稳定消费的数据。

4.1 查询数据源

新版 Notion API 使用 Data Source 查询接口:

JavaScript
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 }),
  },
);

实际实现还处理了分页,并为 4295xx 响应加入指数退避重试。生产环境中不能假设一次请求必然成功。

4.2 映射页面属性

数据库查询结果只包含页面属性,不包含完整正文。我们先把元数据整理成统一的 Post 结构:

JavaScript
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 都可能包含子区块,因此只请求页面第一层会丢失大量内容。

我们的同步器会:

  1. 分页请求 /blocks/{blockId}/children
  2. 检查每个区块的 has_children
  3. 以有限并发递归展开子树。
  4. 删除前端不需要的冗余字段,保存紧凑区块结构。

并发数被限制为 3,目的是在构建速度和 Notion API 限流之间取得平衡。

4.4 把 Notion 图片变成本地静态资源

Notion 文件 URL 往往带有过期签名。如果直接把它写入文章,几小时或几天后图片可能失效。

同步阶段会下载图片,校验 Content-Type 和体积,再以稳定哈希命名保存到 public/notion/

Plain text
public/notion/{block-id}-{sha256摘要}.webp

正文中的远程地址随后被替换成站内地址。这样图片跟随部署进入 CDN,不再依赖临时链接,也减少了读者访问 Notion/S3 的额外网络请求。

4.5 生成快照

同步完成后会产生三个关键结果:

Plain text
content/notion-snapshot.json
public/notion/
public/notion-snapshot-meta.json

第一份包含文章元数据和正文区块;第二个目录保存本地化图片;第三份只保存生成时间、最近编辑时间和文章数量,供线上新鲜度检查使用。

五、失败时不要发布“空博客”

构建系统必须把“无法获得新内容”和“确认内容为空”区分开。

如果 Notion 请求失败,而仓库中已经有上一次成功快照,我们会保留旧内容并继续构建:

JavaScript
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 的组合顺序嵌套。

公式渲染示例:

E=mc2E = mc^2
TypeScript
const html = katex.renderToString(expression, {
  displayMode: true,
  throwOnError: false,
  output: "htmlAndMathml",
});

七、用 Netlify 构建并托管

项目的构建命令是:

Shell
npm run build:netlify

它先执行 Notion 同步,再执行 Vite/vinext 构建:

JSON
{
  "scripts": {
    "sync:notion": "node scripts/sync-notion.mjs",
    "build:netlify": "node scripts/sync-notion.mjs && vite build"
  }
}

netlify.toml 中指定发布目录:

toml
[build]
  command = "npm run build:netlify"
  publish = "dist"

[build.environment]
  NODE_VERSION = "22.14.0"
  NITRO_PRESET = "netlify"

需要在 Netlify 项目环境变量中设置:

JavaScript
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 暴露以下入口:

Plain text
https://kirawii.cn/api/notion-webhook

Notion Webhook 发送事件后,函数先使用 NOTION_WEBHOOK_SECRET 验证 x-notion-signature,然后只接受白名单中的页面事件:

Plain text
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 中。

配置流程如下:

  1. 创建公开仓库,例如 Kirawii/kirawii-comments
  2. 在仓库 Settings 中开启 Discussions。
  3. 安装 Giscus GitHub App,并只授权评论仓库。
  4. giscus.app 选择仓库和 Announcements 分类。
  5. 将生成的仓库 ID 与分类 ID 写入 Netlify 环境变量。
JavaScript
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,兼顾可访问性和低性能设备。

一个很实用的原则是:动画应该解释状态变化,而不是延迟状态变化。 页面切换动画可以帮助用户理解“我从哪里来到哪里”,但不能让链接点击后的响应显得迟钝。

十一、本地开发与发布

安装依赖并启动开发环境:

Shell
npm install
npm run dev

本地同步 Notion:

Shell
npm run sync:notion

提交前检查:

Shell
npm run lint
npm test

如果本机没有 Notion 凭据,同步脚本会继续使用仓库中的最后成功快照,方便前端开发者离线调整界面。

手动发布到 Netlify:

Shell
npx netlify deploy --prod --build

日常使用时则不需要执行这些命令:在 Notion 完成写作,填写元数据,将 type 设为 Poststatus 设为 Published,Webhook 会自动完成后续流程。

十二、常见故障排查

12.1 Published 文章没有出现

依次检查:

  1. status 是否精确为 Published
  2. type 是否为 Post
  3. titleslug 是否非空;
  4. Integration 是否已经连接到数据库;
  5. Netlify 的 NOTION_TOKEN 和 Data Source ID 是否配置;
  6. 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 + 构建时同步 + 静态部署的原因。