从零搭建个人博客:技术选型、架构设计与踩坑记录"use cache"
cacheTag
其实也考虑过 NestJS + React 的传统前后端分离架构,但对个人项目来说太重了——需要同时维护两个项目,后端还要写一大堆 CRUD 接口,反而不如直接在数据库层做权限控制和数据操作来得简单高效。(我单方面宣布 PostgreSQL 是世界上最好的数据库。)
Supabase(PostgreSQL + Auth + Storage)
- 数据库:PostgreSQL,支持 PostGIS,RLS(Row Level Security)能直接在数据库层做权限控制
- 认证:GitHub / Google OAuth 开箱即用,
@supabase/ssr 支持服务端 cookie 管理
- 存储:图片上传直接用 Supabase Storage,不需要再对接 S3 或 OSS
相比于自己搭建 Express/Fastify 后端 + Passport.js 认证 + 对象存储,Supabase 直接省掉了一个后端项目。且 Supabase 每个月都提供非常慷慨的免费额度,对于个人站点的体量,完全够用。
Tailwind 做组件级样式非常快,但全局变量、复杂选择器、动画关键帧这些场景还是 SCSS 更合适。两者混用:Tailwind 负责 80% 的布局和组件样式,SCSS 处理全局变量、mixins 和需要精细控制的模块。
next-intl 是 Next.js 生态里最主流的国际化方案,但对这个项目来说太重了——它并没有适配 Next.js 16 最新的 Cache Component 特性。整个站点只有两个 locale(en-US 和 zh-CN),翻译文件加起来几百行,没必要为此引入一个框架。
所以我写了一个轻量的翻译函数,核心不到 50 行代码:
- 用 namespace + dot-path 做 key 查找(
t("Navigation.posts") → "文章")
- 支持
{placeholder} 插值
t.rich(key, values) 支持在翻译文本中嵌入 React 组件(比如 <link>点击这里</link> → 渲染成 <a> 标签)
相比于引入一个完整框架,这点代码完全可控,性能零开销。
- Framer Motion:首页的打字机动画、滚动进入动画。CSS animation 做不了复杂编排
- CodeMirror:后台的 Markdown 编辑器,相比 Monaco 轻量很多,对移动端也更友好
- Biome:替代 ESLint + Prettier,一个工具同时做 lint 和格式化,配置简单,速度快
- react-markdown + remark 插件:Markdown 渲染选 react-markdown 是因为它把 AST 暴露出来,可以写自定义 remark 插件做指令扩展
:meta{url="https://music.apple.com/us/new"}
@startuml
aaaaaaaaa -> bbbbbbbbb : hello
bbbbbbbbb -> ccccccccc : hello
ccccccccc -> ddddddddd : hello
ddddddddd -> eeeeeeeee : hello
@enduml
这些指令会被自定义的 remark 插件解析成特定的组件,而不是标准的 HTML。相比于直接在 Markdown 里写 JSX 或 HTML,这种方式在切换渲染器或做纯文本导出时更干净。
src/
├── app/[locale]/ # 页面路由(locale 前缀)
│ ├── (index)/ # 公开页面(首页、文章、碎碎念、事件)
│ ├── auth/ # OAuth 登录
│ └── dashboard/ # 管理后台
├── lib/
│ ├── client/ # 浏览器端 Supabase、主题、搜索
│ ├── server/ # 服务端 Supabase、缓存标签
│ └── shared/ # 共享服务层、i18n、配置工具
├── components/
│ ├── features/ # 业务组件(文章、搜索、标签等)
│ ├── shared/ # 共享 UI(主题切换、语言切换)
│ └── ui/ # 通用 UI 原语(Button、Modal 等)
└── types/ # Supabase 生成的类型 + 聚合类型
- **主页(公开页面)**是面向读者的,需要尽可能快的响应速度。所以它用
"use cache" 把渲染结果缓存起来,读者访问时直接拿缓存,不查数据库,接近静态页面的性能。
- **后台(Dashboard)**是编辑界面,需要实时反映数据变更。所以它不走缓存,直接查数据库,保存完立即看到最新结果。
两种策略通过 cacheTag 和 revalidateTag 的配合衔接:后台编辑数据 → Supabase 触发器通知 webhook → revalidateTag 精准失效缓存 → 主页下次访问自动重新渲染。这保证了主页的性能,同时后台的数据变更能自动同步到公开页面。
单纯的 SSR 每次请求都要从 Vercel 到 Supabase 走一个数据库往返,慢。单纯的 SSG 构建时生成,发布文章要重新部署。
Next.js 16 的 "use cache" 提供了第三条路:渲染时缓存,数据变更时精确失效。
组件声明 "use cache" 后,渲染结果被缓存。关键是如何让缓存知道"该失效了"。我的做法是:
- 页面组件和
generateMetadata 分别用 cacheTag() 打上标签(blog:posts、blog:summary、blog:posts:${slug} 等)
- Supabase 数据库触发器 →
pg_net 扩展发 HTTP POST → 站点的 /api/webhook 路由
- Webhook 路由根据变更的表名,查出对应的 cache tag,调用
revalidateTag()
用户保存文章 → Supabase INSERT/UPDATE
→ DB trigger → pg_net HTTP POST
→ /api/webhook → revalidateTag("blog:posts")
→ 下次访问列表页自动重新渲染
这套机制的好处是全自动——作者只管写文章,缓存会自动感知数据变更。不需要登录服务器执行命令,不需要在保存逻辑里到处手动调用 revalidateTag。
- 页面缓存:整个页面的渲染结果被缓存。命中时直接返回 HTML,连数据查询都不会触发。
- 数据层缓存:在数据获取函数上也加了
"use cache"。比如 fetchConfigs() 这个函数被首页、layout、generateMetadata 等多个地方调用——如果只有页面缓存,高并发下页面缓存未命中时,同一个请求可能多次查询数据库。数据层缓存确保即使多个调用方同时请求同一份数据,也只查一次数据库。
以 config 为例:站点的标题、关于我、播放列表这些信息存在 configs 表里,至少被四五个地方读取。数据层缓存打上 blog:config 标签,所有调用方共享同一份缓存,数据库压力降到最低,同时 webhook 触发 revalidateTag 时双层缓存一起失效,数据的实时性丝毫不受影响。
(虽然一个个人站点根本谈不上什么高并发,但这个设计本身让我很满意。)
传统的权限模型是在 API 层校验——每个接口先查用户身份,再判断有没有权限。问题是:漏掉一个接口就是安全漏洞。
之所以这样设计,是因为 Dashboard 是浏览器直接访问 Supabase 的,没有经过 Next.js 服务端转发。这么做有两个好处:一是少了一跳,响应更快;二是为后续扩展做准备——如果以后加评论、点赞这类功能,它们也会走"客户端直连 Supabase"这条线路,RLS 天然保证安全,不需要再套一层 API。
Supabase 的 RLS 把这个逻辑推到了数据库引擎内部:
CREATE POLICY "Public read posts" ON posts FOR SELECT
USING (status = 'show' OR is_admin());
CREATE POLICY "Admin insert posts" ON posts FOR INSERT
WITH CHECK (is_admin());
is_admin() 是一个数据库函数,直接从 JWT 的 app_metadata 里读角色信息。这意味着即使用户拿到了 Supabase 的 anon key,也永远访问不到 status='hide' 的文章——数据库引擎在 SQL 执行前就拦截了。
应用层不需要写一行权限校验代码。只要 Supabase 客户端用的是正确的 key(anon key 或 service role key),RLS 自动生效。
一个常见误区是上来就找最"完整"的 i18n 框架。实际上大多数个人站点只需要:
- 按路径区分语言(
/en-US/posts/hello vs /zh-CN/posts/hello)
- JSON 文件存翻译
- 支持变量插值和简单的富文本
const t = getT("IndexHome", locale);
t("latestPosts.cardTitle");
t("searchCount", { count: 5 });
t.rich("welcome", {
link: (text) => <a href="/profile">{text}</a>
});
关键是 t.rich——它让翻译文件中的 <tag>text</tag> 标记可以被替换为 React 组件,解决了"翻译中需要嵌入链接、加粗等富文本"的难题,而不需要在翻译文件里塞 HTML。
站内搜索的需求很简单:用户输入关键词,匹配文章标题/内容、碎碎念内容、事件标题/内容。
第一反应可能是 Elasticsearch——对于几千篇文章的量级来说纯属过度设计。PostgreSQL 的 ILIKE + 简单的相关度排序完全满足需求:
SELECT 'post' as type, id, title,
ts_headline(content, plainto_tsquery(query)) as snippet
FROM posts
WHERE status = 'show'
AND (title ILIKE '%' || query || '%'
OR content ILIKE '%' || query || '%')
包装成一个 Supabase RPC 函数,前端一行 rpc('search_content', { query }) 调用。没有额外的搜索服务,没有索引同步延迟,部署和维护成本为零。
后台编辑要弹窗、新建文章要弹窗、确认删除要弹窗——经常出现"弹窗里再弹窗"的场景,所以我无疑是需要一个支持堆叠的 Modal 系统的。
市面上大多数组件库的 Modal 都过于模板化,样式和行为很难定制。我想要的是一些特殊的效果——比如弹窗里再弹窗时,底层自动 dim 但不要完全遮挡,关闭子弹窗后父弹窗恢复。还有一个需求可能比较小众,但我觉得是个有意思的设计,基于锚点来定位。主页和 Dashboard 的锚点位置不同,Modal 的弹出位置也会跟着变,在视觉上和触发的按钮或区域形成呼应。
- 每个 modal 有独立的 id 和 z-index
- 支持堆叠:在 modal 里打开另一个 modal,底层自动 dim
- 关闭时按栈顺序弹出,不会一次性全关
ModalProvider (Context)
├── Stack Manager (维护 modal 栈)
├── useModal() hook (push / pop / closeAll)
└── 自动处理 body scroll lock 和 backdrop
本质上就是一个简单的栈数据结构 + Context 下发,不需要引入第三方库。
其实最开始写的时候还觉得没啥必要,直接在服务端和客户端分别写两套 CRUD 就好了。后来越写越觉得重复代码太多了,尤其是一些复杂的查询逻辑(比如带标签联查的文章列表),维护两套实在是太麻烦了。于是抽象出一层 shared services,接受可选的 SupabaseClient 参数,一套查询逻辑,两个场景复用。
- Shared 层:静态公开客户端,默认用于公开页面的只读查询
- Server 层:通过
@supabase/ssr 读取 cookie 中的 session,用于需要认证的服务端操作
- Client 层:浏览器端客户端,用于需要实时 session 的场景
所有 CRUD 逻辑写在 lib/shared/services/ 里,接受一个可选的 SupabaseClient 参数。同一套代码,传不同的客户端,就能在公开页面(无权限)和后台页面(admin 权限)之间复用。不会出现"公开接口和后台接口各写一套 SQL"的情况。
这个项目在动手写代码之前就构思好了整体架构,加上 AI 辅助,写起来还是比较快的,基本上只用了一部分业余时间来完善和优化细节。
标题里写的是"踩坑记录",但说实话,因为从一开始就追求轻量、反感引入第三方库,整个过程中反而没遇到什么真正的"坑"——依赖少,问题就少。更有意思的反而是那些设计上的取舍和权衡:选什么、不选什么、为什么。最大的感受是:个人项目的技术选型,最大的奢侈是"只用你需要的"。
不需要微服务,一个 Next.js 应用就够。不需要独立的搜索服务,PostgreSQL 的全文搜索就够。不需要重量级 i18n 框架,50 行代码的翻译函数就够。不需要自建后端,Supabase 提供的 BaaS 就够。
每一层都选"刚好够用"的方案,最终拼出来的是一个维护成本极低、性能不错、自己完全掌控的系统。这可能就是自己写网站最大的乐趣——不是为了造轮子而造轮子,而是每个轮子都恰好是你想要的尺寸。