我是怎么搭建 YeagerZhao Blog 的:Next.js 15 + Notion + 阿里云 OSS 完整架构
这是一份给我自己以后维护用,也希望同样在做个人站的朋友能照着抄的架构梳理。不光列技术栈,而是从目录树、路由、数据层、内容发布流水线、图片处理、缓存、国际化、视觉系统一路写到 Docker + Nginx 部署。目标是把
YeagerZhao_Blog这个项目讲成一篇别人能读懂、自己以后也能照着维护的架构文。

1. 项目定位
YeagerZhao_Blog 是我的个人网站,线上地址 https://yeagerzhao.com。它把 Notion 当作 Headless CMS,用 Next.js 15 负责页面渲染、SEO、API 网关和缓存,用阿里云 OSS 存照片,用 Docker + Nginx 部署在阿里云 ECS 上。
要解决的核心问题就四个:
- 写作要轻:内容直接在 Notion 里写,不需要每次改一段文字都提 PR、跑 CI。
- 访问要快:访客看到的是独立站点(域名 + 视觉自定义),不是 Notion 原生页面那种灰白模板。
- 设计要自由:字体、配色、动效、文章排版全在代码里掌控。
- 照片要有版权保护:每张相册图本地先压成 ~8MP master、嵌一个 DWT-DCT-SVD 频域盲水印,再上 OSS;浏览器看到的图都是经过实时压缩派生的小图,原图只在点击放大时下发。
一句话概括:Notion 负责内容生产,Next.js 负责内容发布,OSS 负责图片分发,Nginx + Docker 负责稳定运行。
2. 总体架构
整个系统画成一张图大概是这样:

用户浏览器
|
| HTTPS
v
Nginx on 阿里云 ECS
|
| /_next/static 长缓存;其他反代 127.0.0.1:3000
v
Docker 容器:yeagerzhao-blog
|
| Next.js 15 standalone server
| ├─ RSC 渲染(home / blog / product / journey / about)
| ├─ unstable_cache + 内存层(1h TTL)
| ├─ API Routes:/api/revalidate、/api/notion-image
| └─ generateStaticParams 预渲染所有 slug
v
┌─────────────────────────┬──────────────────────────┐
v v v
Notion API OSS(图床) 本地 SKILLS 脚本
├─ content DB ├─ 相册 master ├─ publish-content.mjs
│ (Blog+Product) │ (8MP + 盲水印) ├─ publish-journey.mjs
├─ journey DB └─ ?x-oss-process=... ├─ rewatermark-album.mjs
├─ journey_photo DB thumb/preview/full └─ publish-about.mjs
└─ about DB 边缘节点实时派生它没有传统意义上的"独立后端"。Next.js 自己同时承担四件事:
- 页面服务:home、blog 列表/详情、product 列表/详情、journey 列表/相册详情、about——全部 RSC。
- 数据网关:
/api/notion-image给 Notion 临时 S3 链接做代理,/api/revalidate给 Notion / 脚本一个推送入口。 - 内容适配层:把 Notion 的 4 张数据库转成站点用的 TypeScript 类型。
- 静态资源代理:图片本身的 CDN 由 OSS 承担,Next.js 只服务自家的
_next/static和public/。
这种"内容/资产分家"的设计对个人站非常合适:文章和图片更新频率不一样,缓存策略也不一样,没必要塞到同一个 Node 进程里硬扛。
3. 技术栈
| 层级 | 技术 | 版本 | 作用 |
|---|---|---|---|
| 应用框架 | Next.js | 15.5 | App Router、RSC、ISR、API Routes、standalone 输出 |
| UI 运行时 | React | 19.1 | RSC + 客户端 hydration |
| 类型系统 | TypeScript | 5 | BlogPost / Product / JourneyItem / AboutSection 等核心类型 |
| 样式系统 | Tailwind CSS | v4 | CSS-first:@theme inline + CSS 变量 |
| 内容源 | Notion | — | 4 个数据库:content / journey / journey_photo / about |
| Notion SDK | @notionhq/client | 5.22 | v5 dataSources API + 自实现的兼容 shim |
| 国际化 | next-intl | 4.9 | /zh、/en 路由 + UI 文案 |
| 图片上传 | ali-oss | 6.23 | 本地脚本写入 OSS bucket |
| 图片压缩 | sharp | dev | mozjpeg q=88、4:2:0 色度抽样、按 MP 预算缩放 |
| 盲水印 | invisible-watermark(Python) | conda env | DWT-DCT-SVD 频域水印 |
| 字体 | @fontsource-variable/* | 5.2 | Noto Sans/Serif SC + Newsreader + Spline Mono |
| 部署 | Docker | — | 多阶段 standalone 镜像 |
| 入口代理 | Nginx | — | HTTPS、反代、_next/static 长缓存 |
值得专门点名的几个"不在了"的依赖:之前我用过 react-notion-x 渲染正文,后来发现它打包过大、还要拽一堆它自己的 CSS 而且都得在客户端跑,于是改成 RSC 服务端自己渲染 Notion blocks,体积一下小了很多——这部分后面有专门一节讲。同样下线的还有自己写的 use-cached-fetch.ts 三层缓存:Next.js 15 的 unstable_cache + tags 已经能做到我想要的效果,没必要再叠一层客户端的状态机。早期还引入过 next-themes 做明暗切换,后来发现单一配色已经能撑住整套视觉系统,就把这个依赖一并删了。
4. 目录结构
按 Next.js App Router 的约定组织,但明确把"服务端数据层(server)"和"展示层(ui)"分开:
YeagerZhao_Blog/
├── messages/ # next-intl 翻译
│ ├── zh.json
│ └── en.json
├── public/ # 静态资源(favicon、图片)
├── src/
│ ├── app/ # Next.js App Router
│ │ ├── [locale]/
│ │ │ ├── layout.tsx # locale-scoped layout(字体、Header/Footer)
│ │ │ ├── page.tsx # 首页
│ │ │ ├── blog/page.tsx # 博客列表
│ │ │ ├── blog/[slug]/page.tsx
│ │ │ ├── product/page.tsx # 产品列表
│ │ │ ├── product/[slug]/page.tsx
│ │ │ ├── journey/page.tsx # 旅行/相册列表
│ │ │ ├── journey/[slug]/page.tsx
│ │ │ ├── about/page.tsx # 关于页
│ │ │ ├── loading.tsx
│ │ │ └── not-found.tsx
│ │ ├── api/
│ │ │ ├── notion-image/route.ts
│ │ │ ├── posts/route.ts
│ │ │ ├── products/route.ts
│ │ │ └── revalidate/route.ts # 按需重新生成(脚本/Notion 推送用)
│ │ ├── layout.tsx # 顶层根 layout
│ │ ├── page.tsx # 根 / 重定向到 /zh
│ │ ├── robots.ts
│ │ └── sitemap.ts
│ ├── server/ # 服务端逻辑(只在 Node 端运行)
│ │ ├── cache/
│ │ ├── images/ # Notion 图片代理 handler
│ │ └── notion/
│ │ ├── client.ts # 单例 Notion client
│ │ ├── content.queries.ts # Blog + Product 统一查询
│ │ ├── blog.queries.ts # 薄包装
│ │ ├── product.queries.ts # 薄包装
│ │ ├── journey.queries.ts # 相册查询
│ │ ├── about.queries.ts
│ │ ├── page-content.ts # 递归拉 Notion blocks(深度 4)
│ │ └── mappers.ts # property → TypeScript 类型
│ ├── ui/ # 展示层(client / server 都可能)
│ │ ├── components/ # 原子组件(Button、Card、Tag、Badge、Panel)
│ │ ├── effects/ # SilkHero 之类的视觉效果
│ │ ├── styles/ # 全局 CSS + Tailwind v4 变量
│ │ ├── tokens/ # CSS 变量定义
│ │ └── views/ # 页面级视图组件
│ │ ├── home/HomeView.tsx
│ │ ├── blog/BlogIndexView.tsx
│ │ ├── product/ProductIndexView.tsx
│ │ ├── journey/JourneyView.tsx
│ │ ├── journey/JourneyAlbumView.tsx
│ │ ├── about/AboutView.tsx
│ │ └── article/
│ │ ├── ArticleDetailView.tsx
│ │ ├── ArticleToc.tsx
│ │ └── NotionBlocks.tsx # 自己写的 Notion 渲染器
│ ├── i18n/
│ ├── lib/
│ │ ├── oss-image.ts # ossThumb(url, {preset}) 派生三档 URL
│ │ ├── embed-video.ts # B站 / YouTube 嵌入 URL 解析
│ │ └── constants.ts
│ └── types/
├── SKILLS/ # 内容发布工具链
│ ├── publish-blog-product/
│ ├── publish-journey-album/
│ ├── publish-journey-video/
│ ├── publish-about/
│ ├── operations/ # revalidate-site / sync-notion / setup-schema
│ └── _shared/content-tools.mjs # Notion 客户端 + SDK v5 兼容 shim
├── Dockerfile
├── deploy.sh
├── middleware.ts
└── next.config.ts几个最关键的文件:
| 文件 | 职责 |
|---|---|
src/server/notion/content.queries.ts | Blog + Product 的统一查询(按 ContentType 字段过滤) |
src/server/notion/journey.queries.ts | 相册 + 照片的查询 + 关联 |
src/server/notion/page-content.ts | 递归拉 Notion 页面的 blocks 树(深度 4) |
src/ui/views/article/NotionBlocks.tsx | 自己写的 Notion blocks → React 渲染器(替代 react-notion-x) |
src/ui/views/article/ArticleToc.tsx | 客户端 TOC:scroll-spy + 平滑滚动 |
src/ui/views/journey/JourneyAlbumView.tsx | 相册详情:轮播、键盘导航、lightbox |
src/lib/oss-image.ts | OSS 实时图片处理的 URL 构造器 |
SKILLS/publish-journey-album/publish-journey.mjs | 相册首发:sharp + 盲水印 + OSS PUT + Notion 写入 |
SKILLS/publish-journey-album/rewatermark-album.mjs | 调流水线参数后重新水印已发布相册(不动 Notion) |
SKILLS/publish-journey-album/watermark.py | Python 盲水印 encode/decode CLI |
Dockerfile | 多阶段 standalone 构建 |
middleware.ts | next-intl 语言路由 |
5. 路由设计
src/app/[locale]/ 下八条页面路由:
/zh 首页
/en
/zh/blog /zh/blog/[slug] 博客列表 + 详情
/en/blog /en/blog/[slug]
/zh/product /zh/product/[slug] 产品列表 + 详情
/en/product /en/product/[slug]
/zh/journey /zh/journey/[slug] 相册列表 + 详情
/en/journey /en/journey/[slug]
/zh/about 关于页
/en/aboutsrc/i18n/routing.ts:
export const routing = defineRouting({
locales: ["zh", "en"],
defaultLocale: "zh",
});middleware.ts 把 next-intl 接入:
import createMiddleware from "next-intl/middleware";
import { routing } from "@/i18n/routing";
export default createMiddleware(routing);
export const config = {
matcher: ["/(zh|en)/:path*", "/((?!_next|_vercel|api|.*\\..*).*)"],
};所有详情路由都用 generateStaticParams() 预生成,对每个 slug 同时输出 zh + en 两条静态路径。访问时如果 ISR 缓存还新就直接给静态 HTML,过期则后台重新生成。
6. 内容数据建模:四个 Notion 数据库
我用了四个 Notion 数据库,而不是更多。设计的取舍是:高度同构的内容尽量挤进同一张表(用 ContentType 区分),高度异构的(比如照片)才单独建表。
| 数据库 | env 变量 | 角色 |
|---|---|---|
| content | NOTION_CONTENT_DATABASE_ID | Blog + Product 共用(ContentType 字段区分) |
| journey | NOTION_JOURNEY_DATABASE_ID | 相册 / Vlog / 混剪 / 游记的元数据 |
| journey_photo | NOTION_JOURNEY_PHOTO_DATABASE_ID | 照片表,通过 Journey relation 关联到 journey 表 |
| about | NOTION_ABOUT_DATABASE_ID | 关于页:profile / timeline / highlight 三类小节(SectionType 字段区分) |
content 数据库
字段(节选):
| 字段 | 类型 | 说明 |
|---|---|---|
| Title | title | 文章/项目标题 |
| ContentType | select (blog / product) | 同一张表区分两种内容 |
| Slug | rich_text | URL slug,zh/en 共用 |
| Language | select (zh / en) | 语言版本 |
| Summary | rich_text | 摘要 |
| Date | date | 发布日期 |
| Tags | multi_select | 标签 |
| Published | checkbox | 是否上线 |
| Featured | checkbox | 是否精选 |
| CoverImage | files | 封面图(脚本通过 Notion file_upload 上传) |
| ProductUrl | url | 仅 product 用 |
主键概念是 ContentType + Slug + Language 三元组——同一篇文章的 zh 和 en 是两行,但共享 slug;同一个项目作为 product 出现 和 同一篇文章作为 blog 出现也可以共用同一个 slug(这次这篇就同时发为 blog 和 product 两条)。

journey + journey_photo 表的拆分
相册和照片做成了两张表 + 一个 relation,理由:
- 一篇相册(journey)有标题、地点、日期、感受、封面这些"列表卡片"需要的字段
- 一篇相册有 N 张照片(journey_photo),每张照片只需要 ImageUrl + Order + 关联到哪篇相册
- 用 relation 联系,列表页只查 journey 表,详情页才把 photos 拉出来
journey 表字段(节选):Title / Slug / Language / Type(album/vlog/anime-edit/travel-note)/ Date / Place / Summary / Feeling / CoverImageUrl / VideoUrl / Published。
journey_photo 表字段:Name / Journey(relation)/ ImageUrl / Order / Published。
about 表的单表多 section 设计
关于页比较杂:有 profile(头像 + 标语)、有 timeline(求学/工作经历)、有 highlights(亮点项目/作品)。如果分三张表会很碎,所以用一张表 SectionType 字段区分:
SectionType: select (profile / timeline / highlight)代码侧 getAboutProfile() / getAboutTimeline() / getAboutHighlights() 各自传不同的过滤条件。
7. 服务端数据层
数据层全部放在 src/server/notion/,只在服务端运行(不会打到客户端 bundle)。这层有三件正经事要做:
7.1 兼容 Notion SDK v5 的 dataSources API
@notionhq/client v5 把数据库查询从 notion.databases.query() 改成了 notion.dataSources.query()(参数名也从 database_id 改成 data_source_id)。为了让代码同时兼容 v4 / v5,SKILLS/_shared/content-tools.mjs 里有一个小 shim:
export async function queryNotionDataSource(notion, args) {
if (notion.databases?.query) return notion.databases.query(args); // v4 老接口
if (notion.dataSources?.query) { // v5 新接口
const { database_id, ...rest } = args;
return notion.dataSources.query({ data_source_id: database_id, ...rest });
}
throw new Error("Installed Notion SDK does not support database/data source queries");
}这个 shim 在网站端的 src/server/notion/ 里也有同名实现,所有查询都走它。日后哪天 v6 又改一次,只改这一处。
7.2 把 Notion property 翻译成 TypeScript 类型
src/server/notion/mappers.ts 写了一组 extractBlogPost(page)、extractJourneyItem(page) 这样的 mapper。它们做三件事:
- 处理 Notion 的 rich_text 数组 → string(取
plain_text拼起来) - 把 select / multi_select 的
name拿出来 - 用合理的默认值兜底(缺字段时不报错,给个空串/空数组)
输出就是 src/types/ 里定义的强类型:BlogPost、Product、JourneyItem、JourneyPhoto、AboutProfile 等等。
7.3 用 unstable_cache 包装查询
每个查询函数外面套一层 Next.js 的 unstable_cache,配上 cache tag:
import { unstable_cache } from "next/cache";
export const getBlogPosts = (locale: Locale) =>
unstable_cache(
async () => fetchFromNotion(...),
["blog-posts", locale],
{ revalidate: 3600, tags: ["notion", "content", "blog-posts"] }
)();ISR 用同一个 tag 体系:
/api/revalidate收到推送 →revalidateTag("blog-posts")→ 下次访问就重新拉 Notion- 也可以一次性
revalidateTag("notion")把所有缓存的 Notion 查询全部失效(部署后我都是直接走这条粗暴路径)
8. 内容发布流水线:从本地 markdown 到 Notion
我刻意不让博客的"写作"步骤依赖 Notion 原生编辑器——Notion 网页端写 markdown 太憋屈,而且不好做本地预览和 git 版本控制。所以发布流水线是:
本地 D:\...\YeagerZhao\content\blog\<slug>\
├── manifest.json # 元数据
├── draft.zh.md # 中文正文
├── draft.en.md # 英文正文
└── images/ # 配图
└── 01-xxx.png # 正文  引用的图
↓ npm run publish:content -- --dir "..."
SKILLS/publish-blog-product/publish-content.mjs
├── 解析 markdown → Notion blocks(自实现解析器)
├── 本地图片 → uploadNotionFile() → file_upload API
├── 在 Notion content 数据库写入 zh + en 两行(Published=false)
└── 用 ContentType + Slug + Language 三元组防重
↓ 我去 Notion 把 Published 改 true
↓ npm run revalidate:site
↓ Next.js revalidateTag("notion") → ISR 重新拉
网站 /zh/blog/<slug> 上线manifest.json schema(脚本会校验):
{
"type": "blog",
"slug": "yeagerzhao-blog-architecture",
"date": "2026-06-22",
"tags": ["Vibe Coding", "个人网站"],
"featured": true,
"cover": "images/00-cover.png",
"zh": { "title": "...", "summary": "..." },
"en": { "title": "...", "summary": "..." }
}整套 SKILL 的好处是:
- 写作本地化:在 VSCode 里写 markdown,git 提交,发布脚本只是"上传到 Notion + 触发 revalidate"
- 图片本地化:
引用本地图,脚本帮我上传到 Notion 的 file_upload API,并在 block 里写好 URL - 双语并行:一次 publish 同时写 zh + en 两行,slug 共用
- 幂等:再跑一遍同 slug 会报错;要更新 properties 加
--overwrite,要换正文就在 Notion 里手动 archive 旧记录再重跑
9. 图片处理流水线:sharp + 盲水印 + OSS 三档实时派生
这部分是整个项目最值得细讲的——既是我做得最仔细的部分,也是从其他个人站很少见到的玩法。
9.1 三件要解决的事
| 目标 | 怎么解决 |
|---|---|
| ① 保护隐私(剥 EXIF GPS / 相机型号) | sharp .rotate() 把方向烧进像素后默认丢弃所有 metadata |
| ② 控制 master 体积(目标 ≤ 1.5MB) | sharp 缩放到 ~8 megapixels + JPEG q=88 mozjpeg 4:2:0 |
| ③ 防盗图(被盗了能溯源) | Python invisible-watermark 在 DWT-DCT-SVD 频域嵌入 "YeagerZhao" 字符串 |
| ④ 浏览器只下载需要的尺寸 | OSS 上只存 1 张 master,三档(thumb / preview / full)由 ?x-oss-process=... 实时派生 |
9.2 本地:8 MP 预算的缩放策略
最早我用"长边封顶 3500px"的方式压缩,结果碰到一张 13223×2360 的全景图——长边砍到 3500 后短边只剩 625px,肉眼看一眼就糊得不行。换成按面积预算之后再没出过问题:
// SKILLS/publish-journey-album/publish-journey.mjs
const TARGET_MASTER_MP = 8_000_000;
async function processPhoto(srcPath) {
const meta = await sharp(srcPath).rotate().metadata();
const sourceMP = meta.width * meta.height;
const scale = Math.min(1, Math.sqrt(TARGET_MASTER_MP / sourceMP));
const newW = Math.round(meta.width * scale);
const newH = Math.round(meta.height * scale);
return sharp(srcPath)
.rotate() // 按 EXIF 旋转
.resize(newW, newH) // 8MP 预算
.jpeg({ quality: 88, mozjpeg: true, chromaSubsampling: "4:2:0" })
.toBuffer(); // 默认丢弃 EXIF/XMP/IPTC
}为什么是 8MP:4K 屏幕原生像素 8.3MP,正好够看;再大浏览器一定会缩,是浪费。
对比真实数据:
| 相册 | 源图 | 之前(长边 3500) | 现在(8MP) |
|---|---|---|---|
| 普通 3:2 风光(24MP) | 11MB | 3500×2334(8.2MP)800KB | 3464×2310(8.0MP)798KB |
| 全景 5.6:1 (31MP) | 19MB | 3500×625(2.2MP)381KB | 6695×1195(8.0MP)1294KB |
全景图短边从 625 升到 1195,差不多翻倍;master 体积只多了 900KB——非常划算。
9.3 本地:盲水印嵌入
sharp 的输出写到临时 JPEG 后调 watermark.py:
execFileSync(PYTHON_EXE, [
WATERMARK_PY, "encode",
"--input", tmpIn, "--output", tmpOut,
"--text", "YeagerZhao",
"--method", "dwtDctSvd", // SVD 变体比纯 dwtDct 更扛得住 JPEG 压缩
"--quality", "80", // 输出 JPEG q=80 → 终态 master
]);为什么选 dwtDctSvd:实测下 dwtDct + q=85 已经丢字(解码 0/10 bytes),换成 dwtDctSvd 后 q=80 仍能解出 10/10 bytes——也就是即使我对方拿到这张图再做一轮 JPEG 压缩,"YeagerZhao" 这个字符串依然能用频域解码工具完整恢复出来。
Python 环境靠 conda 隔离,依赖只两个:
conda create -n yeagerzhao python=3.11 -y
conda activate yeagerzhao
pip install invisible-watermark opencv-pythonNode 这边通过 PYTHON_EXE 环境变量指向 conda 的 python.exe,避免依赖系统 PATH。
9.4 OSS:master 一份,三档实时派生
OSS bucket 里每张图都只存 master,key 是 yeagerzhao/journey/<slug>/photos/NN-<原名>.jpg。浏览器要看不同尺寸的图,由前端在 URL 后面加 ?x-oss-process=image/resize,p_X/format,webp/quality,q_Y,OSS 边缘节点现场缩放、转 webp、调质量再返回。
src/lib/oss-image.ts 里的预设:
const PRESETS = {
// 列表 / 网格封面 — 25% of source, q=70 → ~50KB
thumb: { percent: 25, quality: 70 },
// 相册内单图(轮播) — 40% of source, q=78 → ~200KB
preview: { percent: 40, quality: 78 },
// lightbox:直接返回 master 本体,不做任何处理
};
export function ossThumb(url, { preset }) {
if (preset === "full") return url; // master 不动
if (!isOssUrl(url)) return url; // 非 OSS(B站/YouTube 缩略图等)原样返回
const { percent, quality } = PRESETS[preset];
const sep = url.includes("?") ? "&" : "?";
return `${url}${sep}x-oss-process=image/resize,p_${percent}/format,webp/quality,q_${quality}`;
}p_N 是按源图百分比缩,不是按固定宽度——保证全景图、竖图、方图都按自身比例缩,不会变形。format,webp 让 OSS 边缘转 webp,比 JPEG 再省 ~30%。

9.5 重新水印已发布相册
我的图片流水线是迭代出来的——从最早不压缩、到长边封顶、到 8MP 预算、从 dwtDct 到 dwtDctSvd——每次调整都需要对已发布的相册做批处理。所以单独写了一个 rewatermark-album.mjs:
# 单个相册
node SKILLS/publish-journey-album/rewatermark-album.mjs --dir "<原始素材目录>"
# 批量
for d in "E:/Media/Photo/24Select/"*/ ; do
node SKILLS/publish-journey-album/rewatermark-album.mjs --dir "$d"
done关键设计:OSS key 用同样的 <slug>/photos/NN-<原名>.jpg 规则重建,PUT 覆盖既有对象——所以 Notion 里的 ImageUrl / CoverImageUrl 不变,不需要重写 Notion,也不需要给 Notion 缓存失效,只需 npm run revalidate:site 让 CDN 拉新版即可。
这次我用它一口气把 7 个相册的 87 张照片全部重新打了水印,耗时大概 5 分钟。
9.6 验证:水印是否还在
curl -s -o /tmp/test.jpg "<master URL>"
python SKILLS/publish-journey-album/watermark.py decode \
--input /tmp/test.jpg --method dwtDctSvd --length 80 \
--expect YeagerZhao
# 期望最后一行:match: 10/10 bytes vs expected 'YeagerZhao'--length 80 是因为 encode 时重复了 8 次 "YeagerZhao"(80 字节)来增强容错——只要前 10 字节解出来就 OK,后面 70 字节用 majority vote 兜底。每次发布之后我都会随机抽几张跑这条命令验一遍。
10. NotionBlocks:自己写的 Notion → React 渲染器
最早我用 react-notion-x。后来两个问题让我放弃了它:
- 包大:它把整个 notion react 渲染器全打到客户端,加上
prismjs、katex、mermaid这些重组件,一个博客详情页 client bundle 涨到 200KB+ - 强制 client component:它的
<NotionRenderer>必须在客户端跑,整篇文章的渲染都被迫从 RSC 退到 client——首屏 HTML 里没有正文,对 SEO 和首屏可读性都是浪费
后来换成自己写的 NotionBlocks.tsx:
// 服务端组件 — Notion blocks 直接渲染成 HTML
export function NotionBlocks({ blocks }: { blocks: NotionBlock[] }) {
return <>{blocks.map((b) => renderBlock(b))}</>;
}
function renderBlock(block: NotionBlock) {
switch (block.type) {
case "paragraph": return <p>{renderRichText(block.paragraph.rich_text)}</p>;
case "heading_1": return <h1>{renderRichText(...)}</h1>;
case "heading_2": return <h2>{renderRichText(...)}</h2>;
case "heading_3": return <h3>{renderRichText(...)}</h3>;
case "code": return <CodeBlock language={block.code.language}>{...}</CodeBlock>;
case "image": return <Image src={proxyUrl(block.image)} alt="..." />;
case "quote": return <blockquote>{renderRichText(...)}</blockquote>;
case "bulleted_list_item": /* 折叠相邻 item 成 <ul> */
case "callout": return <Callout icon={...}>{...}</Callout>;
// ...
}
}renderRichText 处理 Notion 的 annotations(bold/italic/code/strikethrough/underline/color + link)。代码块的语法高亮直接走 hljs(CSS only),不拉运行时 JS。
结果:
- 博客详情页 client bundle 减了大概 180KB
- 不用再 override 任何
.notion-*class,正文样式直接复用全站的字体/间距/链接样式 - 整篇 RSC,首屏 HTML 直接带正文出去(SEO 友好)
文章侧栏 TOC(仅这个是 client)
ArticleToc.tsx 是详情页里唯一保留的 client component——它需要 scroll-spy 和点击平滑滚动:
"use client";
function ArticleToc({ headings }) {
const [active, setActive] = useState(headings[0]?.id);
useEffect(() => {
const io = new IntersectionObserver((entries) => {
const visible = entries.find((e) => e.isIntersecting);
if (visible) setActive(visible.target.id);
}, { rootMargin: "-50% 0px -50% 0px" });
headings.forEach((h) => {
const el = document.getElementById(h.id);
if (el) io.observe(el);
});
return () => io.disconnect();
}, [headings]);
// render ...
}headings 是服务端从 NotionBlocks 解析时同步抽出的——客户端不二次解析正文。
11. 视觉系统:Tailwind v4 + CSS 变量
这站只有一套配色——没做明暗切换。理由很简单:单一配色已经能撑住整套视觉系统(深蓝底色 + 月光色文字 + 金色点缀),再做切换反而要为暗/亮两套色卡都调对比度、调链接色、调代码块底色,工作量翻倍但读者收益有限。所以早期引入的 next-themes 也跟着拿掉了(见第 3 节"不在了的依赖")。
Tailwind v4 的 CSS-first 写法让"主题变量"和"utility class"用同一套机制:
/* src/ui/styles/globals.css */
@import "tailwindcss";
@theme inline {
--color-background: var(--background);
--color-foreground: var(--foreground);
--color-accent: var(--c-gold);
--font-sans: var(--font-noto-sans-sc), "Microsoft YaHei", system-ui, sans-serif;
--font-serif: var(--font-newsreader), serif;
--font-mono: var(--font-spline-sans-mono), monospace;
}
:root {
/* 一组深夜蓝 + 月光色 + 金点缀 */
--background: #0a0f1a;
--foreground: #ecede4;
--c-navy: #0e2740;
--c-steel: #3e6b89;
--c-olive: #7a8b6a;
--c-gold: #c8a85b;
--text-strong: #f5f1de;
--text-muted: #8d96a8;
}字体策略:
- 英文标题:
Newsreader(衬线,有书卷气) - 中文:
Noto Sans/Serif SC(fontsource-variable 加载,按字重切片) - 代码:
Spline Sans Mono - 中文系统字体(兜底):微软雅黑 / 苹方
背景不是图片,而是 CSS 生成的两层效果:
body::before:SVG turbulence 噪点,模拟纸张颗粒body::after:多组 radial-gradient,模拟水彩色块
这让页面有质感,但不需要加载任何额外背景图。
还有一个小设计:全站默认禁用文本选择 + 长按下载,只有文章正文和产品详情页才重新允许选择。列表和导航更像一个"应用界面",正文阅读又不影响复制引用。相册的照片更进一步——onContextMenu 和 onDragStart 都阻止默认行为,让右键另存为这条路径走不通。
12. UI 分层:views + components + effects
src/ui/
├── views/ # 页面级视图(每个 page.tsx 引用一个 View)
├── components/ # 原子 + 复合组件(Card / Button / Tag / Badge / Panel / Reveal / Timeline)
├── effects/ # 装饰性视觉效果(SilkHero 之类)
├── styles/ # 全局 CSS
└── tokens/ # CSS 变量定义把 views/ 和 components/ 分开是为了让 page.tsx 保持极简——它只做"取数 + 把数据递给 View"两件事,比如:
// src/app/[locale]/blog/page.tsx
export default async function Page({ params }) {
const { locale } = await params;
const posts = await getBlogPosts(locale);
return <BlogIndexView lang={locale} posts={posts} />;
}View 内部再 compose <Panel>、<Card>、<Tag>、<SectionBlock> 之类的小积木。Server / Client 边界原则:
| 类型 | 代表组件 | 特点 |
|---|---|---|
| Server | 所有 page.tsx、HomeView、BlogIndexView、NotionBlocks | 在服务端取数据并输出 HTML |
| Client | SiteNav、LanguageSwitcher、ArticleToc、JourneyAlbumView、Reveal | 需要 hooks、事件、滚动监听或浏览器状态 |
简单原则:能在服务端完成的尽量在服务端完成;只有交互、滚动和浏览器状态才放到客户端。
13. 国际化与中英文路由
国际化两层:
第一层是 UI 文案——messages/zh.json 和 messages/en.json 存所有导航、按钮、页面标题、空状态文字。组件里:
const t = useTranslations("common");
t("back"); // → "返回" / "Back"第二层是路由——/zh 和 /en 是两套语言入口,src/i18n/navigation.ts 导出的 Link 会自动处理 locale-aware navigation:
import { Link } from "@/i18n/navigation";
<Link href="/blog/my-post">阅读</Link>
// 在 /zh 下渲染成 /zh/blog/my-post,在 /en 下渲染成 /en/blog/my-post内容侧的语言策略:每篇文章在 Notion 里都有 zh 和 en 两行,getBlogPosts(locale) 会按 Language 字段过滤。所以 /zh/blog/my-post 和 /en/blog/my-post 真的是两份内容,不是 UI 翻译 + 同一份正文。
14. 缓存策略
不像旧版那种"三层缓存"的复杂事,现在简化到了两层 + ISR:
┌─────────────────────────────────────┐
│ Layer A:unstable_cache + tags │ Next.js 15 内置
│ ├─ revalidate: 3600(1h ISR) │
│ └─ tags: ["notion", "content", "journey", ...]
└─────────────────────────────────────┘
↓
┌─────────────────────────────────────┐
│ Layer B:进程内 Map<key, {data, ts}>│ 自己写的轻量层
│ ├─ TTL: 1h │
│ └─ 单 Node 进程内有效(容器重启清空)│
└─────────────────────────────────────┘
↓
Notion APILayer A 是 Next.js 自家的 unstable_cache()——既负责 RSC 拿数据的缓存,也负责 ISR 的页面缓存(同一组 tag)。
Layer B 是个简单的 Map,主要是在 Layer A 还没"暖"起来之前(容器刚启动),减少多个并发 RSC 调用同一个 Notion query 的次数。
需要让缓存失效时只有一条路:POST /api/revalidate 带上 Authorization: Bearer $REVALIDATE_SECRET,handler 内部调 revalidateTag("notion") 把所有 tag 含 "notion" 的缓存一起失效。SKILLS/operations/revalidate-site.mjs 就是这条命令的脚本封装:
npm run revalidate:site
# → POST $NEXT_PUBLIC_SITE_URL/api/revalidate
# → { "revalidated": true, "tags": ["notion", "content", "blog-posts", ...] }15. API Routes
只有三个:
| 路径 | 作用 |
|---|---|
/api/notion-image?url=...&blockId=... | 代理 Notion 文件 S3 临时 URL(避免过期 / 跨域 / 缓存不可控) |
/api/revalidate | 按需失效 Next.js 缓存(脚本/Notion webhook 用) |
/api/posts、/api/products | 列表数据 JSON(保留给可能的客户端拉取,但当前所有 view 都是 RSC) |
/api/notion-image 设缓存:
"Cache-Control": "public, max-age=86400, s-maxage=604800"浏览器缓存 1 天,CDN/共享缓存 7 天。
/api/revalidate 用 Authorization: Bearer 校验:
export async function POST(req: Request) {
const auth = req.headers.get("authorization");
if (auth !== `Bearer ${process.env.REVALIDATE_SECRET}`) {
return new Response("Unauthorized", { status: 401 });
}
const tags = ["notion", "content", "blog-posts", "products", "journey", "about"];
tags.forEach((tag) => revalidateTag(tag));
return Response.json({ revalidated: true, tags, revalidatedAt: new Date() });
}16. SEO 与站点索引
Next.js 服务端生成页面 + RSC,SEO 基础天然到位。详情页用 generateMetadata() 从 Notion 元数据生成:
export async function generateMetadata({ params }) {
const { locale, slug } = await params;
const post = await getBlogPostBySlug(slug, locale);
return {
title: post.title,
description: post.summary,
openGraph: {
title: post.title,
description: post.summary,
type: "article",
publishedTime: post.date,
tags: post.tags,
images: post.coverImage ? [{ url: post.coverImage }] : undefined,
},
};
}metadataBase 设成 https://yeagerzhao.com,所有相对 URL 都能正确转成绝对 URL。
另外有:
| 文件 | 作用 |
|---|---|
src/app/sitemap.ts | 自动遍历四个数据库生成 sitemap |
src/app/robots.ts | 生成 robots.txt |
17. Docker + Nginx + 阿里云 ECS 部署
Dockerfile 是经典的多阶段 standalone:
node:20-alpine (base)
|
v
deps:npm ci
|
v
builder:注入 NOTION_* env → npm run build
|
v
runner:复制 .next/standalone + .next/static + public/
USER nextjs
EXPOSE 3000
CMD ["node", "server.js"]构建出来的镜像只有 ~200MB,可以跑在最便宜的阿里云 ECS(2 核 2G)上。
端口绑定:
docker run -d \
--name yeagerzhao-blog \
--restart always \
-p 127.0.0.1:3000:3000 \ # 注意是 127.0.0.1,外网不能直接打到容器
--env-file .env.local \
yeagerzhao-blogNginx 的职责:
- 监听 80/443,做 HTTPS 终止(证书走 certbot)
- 把请求反代到
127.0.0.1:3000 - 给
/_next/static/长缓存
典型配置:
server {
listen 443 ssl http2;
server_name yeagerzhao.com www.yeagerzhao.com;
location /_next/static/ {
proxy_pass http://127.0.0.1:3000;
add_header Cache-Control "public, max-age=31536000, immutable";
}
location /favicon.ico {
proxy_pass http://127.0.0.1:3000;
add_header Cache-Control "public, max-age=86400";
}
location / {
proxy_pass http://127.0.0.1:3000;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Real-IP $remote_addr;
}
}deploy.sh 把"装 Docker、装 Nginx、build、docker run、生成 Nginx 配置"一键化,新装 ECS 上 5 分钟就能跑起来。

18. 日常工作流
写一篇新博客
# 1. 本地新建目录
mkdir -p "D:\...\YeagerZhao\content\blog\<slug>"
# 在里面新建 manifest.json + draft.zh.md + draft.en.md + images/
# 2. dry-run 看 markdown 解析成多少个 Notion blocks
npm run publish:content -- --dir "...<slug>" --dry-run
# 3. 真发布(写 Notion,Published=false)
npm run publish:content -- --dir "...<slug>"
# 4. 去 Notion 审一遍,把 Published 改 true
# 5. 让网站拉新版
npm run revalidate:site发布一个相册
# 1. 本地准备 D:\...\<相册文件夹>\
# ├── manifest.json # type/slug/date/title/summary/feeling/place
# ├── cover.jpg # 可选
# └── xxx.jpg ...
# 2. 看上传计划
npm run publish:journey -- --dir "..." --dry-run
# 3. 真发布(自动压缩 + 盲水印 + OSS + Notion)
npm run publish:journey -- --dir "..."
# 4. Notion 改 Published=true,然后 revalidate
node SKILLS/publish-journey-album/mark-journey-published.mjs --slug ...
npm run revalidate:site部署代码改动
# 本地
git add . && git commit -m "..." && git push
# 服务器
ssh yeagerzhao.com
cd /opt/blog
git pull
docker build \
--build-arg NOTION_API_KEY=... \
--build-arg NOTION_CONTENT_DATABASE_ID=... \
--build-arg NOTION_JOURNEY_DATABASE_ID=... \
--build-arg NOTION_JOURNEY_PHOTO_DATABASE_ID=... \
--build-arg NOTION_ABOUT_DATABASE_ID=... \
-t yeagerzhao-blog .
docker stop yeagerzhao-blog && docker rm yeagerzhao-blog
docker run -d --name yeagerzhao-blog --restart always \
-p 127.0.0.1:3000:3000 --env-file .env.local yeagerzhao-blog内容更新不需要重新部署——直接走 publish 脚本 + revalidate。
19. 关键取舍
为什么不用 Vercel 直接托管?
Vercel 对 Next.js 是最省心的,但这个站要服务国内访问,自托管 ECS + Nginx 比 Vercel 国内访问稳定得多,证书也好控。代价是自己得维护 Docker + 服务器。这个权衡对个人站来说划算。
为什么 Blog 和 Product 共用一张 content 表?
这次发布这篇文章的时候我就给它同时打了 Blog 和 Product 两个 type——同一篇内容在 /blog/<slug> 和 /product/<slug> 两条路径都能进。如果分两张表,要嘛重复维护,要嘛得加一层 join。共用一张表 + ContentType 字段,逻辑简单很多。
为什么相册要单独走 OSS,而不是也塞进 Notion?
Notion 的图片字段会返回临时 S3 URL(30 分钟过期),完全不适合做相册——浏览器一打开就全部 404。OSS 是公开 URL + 长缓存 + 实时图片处理,正好补这个空。Notion 在相册流程里只存 OSS 的 master URL(一段不会变的字符串),不再充当"图床"。
为什么要做盲水印?
被盗图能有手段维权。明水印能被一秒裁掉或者 cv2 inpaint 掉;DWT-DCT-SVD 频域水印不破坏视觉、扛 JPEG 再压缩、扛缩放、扛轻度裁切——只要对方没大改图,"YeagerZhao" 这个字符串能完整解出来。这对一个个人摄影 + 写作站来说,0 成本但有威慑力。
为什么自己写 NotionBlocks 而不是用 react-notion-x?
体积 + RSC 两个综合考虑(第 10 节展开过)。代价是 markdown 里不能用 Notion 的全部 block 类型(database view、synced block、AI block 这些直接 ignore),但我自己博客里基本不会用这些。
为什么不复制源图到本地 archive?
之前 publish 脚本会把每个相册的源图复制一份到 archive/journey/<slug>-<timestamp>/,9 个相册下来占了 1.1GB。这玩意儿本质是源图的冗余副本——我自己 E:\ 里已经有原始素材,archive 反而碍事。所以现在 publish 不再复制,需要重新水印就直接指向 E:\ 里的原始目录跑 rewatermark-album.mjs。
20. 局限与下一步
当前架构还不完美的地方:
- on-demand revalidation 没接 Notion webhook:现在改完 Notion 还需要手动
npm run revalidate:site。理想是 Notion 改完自动推到/api/revalidate。Notion 自己的 webhook 不太成熟,可能要走 Zapier 或自建 polling - Layer B 内存缓存进程级:容器重启后第一次访问要打 Notion API,可以考虑迁到 Redis 或 SQLite 持久化
- 没有搜索 / 标签页 / 归档页:当前博客列表只能按时间倒序,没有按 tag 过滤、没有按年归档。加这些不难,但要先想清楚 URL 结构
- 盲水印只挡得住"截屏 + 重压缩"那一类:如果对方做了重度修图(大幅裁切、color grading、CLAHE)水印也会丢。再保守一点可以考虑加一道像素级水印 hash(perceptual hashing)做最后一道兜底
- 没有评论 / 订阅:目前只能微信扫码或邮件联系,未来要做评论的话可能走 giscus 这种 GitHub Issues 方案
下一步可能做的:
- 给图片代理
/api/notion-image增加来源白名单,避免被白嫖成开放代理 - 给文章详情加阅读进度条 + 估算阅读时间
- 为博客正文做"中英文互链"——当前同一篇文章 zh/en 是独立两行,但页面右上角的语言切换是基于路径硬切,不是基于 slug 的智能匹配
- 增加 GitHub Actions 自动部署,省掉手动 SSH
21. 总结
YeagerZhao_Blog 的核心设计哲学是:每一层只做自己最擅长的事情。
- Notion 做内容创作(写作体验 + 协作 + 移动端)
- 本地 markdown + SKILLS 脚本 做内容工程(git 版本控制 + 可重放发布)
- sharp + invisible-watermark + OSS 做图片资产(压缩 + 防盗 + CDN)
- Next.js 15 做页面生成(RSC + ISR + i18n + SEO)
- Tailwind v4 + CSS 变量 做视觉系统(单一配色 + 字体策略)
- Docker + Nginx + 阿里云 ECS 做运行环境(封装 + 反代 + HTTPS)
最终得到的是一个维护成本低、访问速度快、视觉可定制、内容更新轻量、照片有版权保护的个人站点。它不是最复杂的架构,但它非常适合个人博客:足够现代,足够快,也足够可控。