从零搭建个人博客:botao.site 建站全记录
为什么要搭一个自己的博客
写了这么久代码,一直没有一个自己的地方沉淀东西(懒)。 我想要一个足够简单的博客:用 Markdown 写作,支持暗色模式,设计上干净利落,部署不用操心。
于是就有了 botao.site。
技术选型:为什么是 Nuxt + Content
选框架的时候考虑过几个方案:
- Hugo / VitePress:纯静态,够快,但扩展性有限,以后想加动态功能会很别扭
- Next.js:生态强大,React 生态,但我感觉比较重,更 prefer Vue 生态
- Astro:内容站利器,不过写起来还是没有 Vue 那么顺手
最终选了 Nuxt 4。理由很简单——熟悉 Vue 生态,文件路由省心,SSR/SSG 灵活切换,生态里刚好有 @nuxt/content 这个为博客量身打造的模块。
技术栈一句话概括:
Nuxt 4 + @nuxt/content v3 + Tailwind CSS v3 + Vercel
内容管理用 @nuxt/content,Markdown 文件直接放在 content/blog/ 目录下,用 Zod 定义 frontmatter schema,类型安全:
// content.config.ts
blog: defineCollection({
type: 'page',
source: 'blog/**/*.md',
schema: z.object({
title: z.string(),
description: z.string(),
date: z.date(),
tags: z.array(z.string()).default([]),
cover: z.string().optional(),
}),
})
写文章就是写 Markdown,git push 或 vercel deploy 就是发布,没有后台,没有数据库(嗯,严格来说有一个 SQLite,但那是 @nuxt/content 内部用的查询引擎,不用管它)。
一天搭完核心功能
项目的核心功能其实一天就搭完了,12 个 commit 从初始化到一个五脏俱全的博客。
脚手架和基础配置
nuxi init 创建项目,然后配置 Tailwind CSS、@nuxtjs/color-mode(暗色模式)、Shiki 代码高亮。第一个小坑就出现了——@nuxtjs/color-mode v3.6.2 根本不存在,最新是 v4.0.0。这种依赖版本的问题后面还会反复遇到。
页面和组件
一口气搞定了 6 个页面路由:
- 首页:展示最新 5 篇文章
- 博客列表:所有文章按年份分组,年份大字做背景
- 文章详情:阅读时间估算(中文按每分钟 300 字算)、上下篇导航
- 标签页:所有标签和对应文章数
- 标签筛选:点击标签查看相关文章
- 关于页:简单的自我介绍
组件很克制,只做了四个:AppHeader(顶部导航)、AppFooter、BlogCard(文章卡片)、TagBadge(标签徽章),加一个 ThemeToggle(三态主题切换:跟随系统 / 亮色 / 暗色)。
Tailwind v4 vs v3 的坑
这天最大的坑是 Tailwind 版本语法。Claude Code 一开始生成了 v4 语法的 CSS:
/* v4 语法(错误) */
@import 'tailwindcss';
但 @nuxtjs/tailwindcss v6 实际用的是 Tailwind v3,正确写法是:
/* v3 语法(正确) */
@tailwind base;
@tailwind components;
@tailwind utilities;
这个问题排查了一会儿,因为报错信息不太直观。教训就是:用集成模块时,要确认它实际引入的依赖版本,而不是想当然用最新语法。
设计理念:极简但不简陋
审美上追求的是"少即是多"。
CSS 变量驱动主题
整个站点的颜色由一组语义化 CSS 变量控制,暗色模式只需要在 .dark 类下覆盖这些变量:
:root {
--color-bg: #FFFFFF;
--color-fg: #1A1A1A;
--color-muted: #6B7280;
--color-border: #E5E7EB;
}
html.dark {
--color-bg: #0A0A0A;
--color-fg: #E5E5E5;
--color-muted: #9CA3AF;
--color-border: #27272A;
}
这样做的好处是,组件里不需要到处写 dark: 前缀,大部分样式通过变量自动跟随主题切换。
格子背景
设计上最有辨识度的元素是背景的格子纹理。用四层 CSS linear-gradient 叠出大格子(100px)和小格子(10px):
body {
background-image:
linear-gradient(var(--pattern-fg-bold) 1px, transparent 1px),
linear-gradient(90deg, var(--pattern-fg-bold) 1px, transparent 1px),
linear-gradient(var(--pattern-fg) 1px, transparent 1px),
linear-gradient(90deg, var(--pattern-fg) 1px, transparent 1px);
background-size: 100px 100px, 100px 100px, 10px 10px, 10px 10px;
}
内容区域是纯白/纯黑背景,和格子背景形成层次感。简单但有效。
字体选了 Inter(正文)和 Fira Code(代码块),都从 Google Fonts 加载。代码高亮用 Shiki 的 github-light / github-dark 双主题,跟随暗色模式自动切换。
部署到 Vercel:一波三折
本地开发一切顺利,部署到 Vercel 就是另一个故事了。这个阶段贡献了 7 个 commit,基本都是配置调试。
构建方式的纠结
一开始想用 pnpm generate 做静态生成,还配了 Nitro 的 prerender 选项。结果发现 @nuxt/content 的某些动态查询在纯静态模式下表现不一致,果断改回 pnpm build(SSR 模式)。
framework 字段之谜
vercel.json 里的 framework 字段,到底该填 "nuxt" 还是 "nuxtjs"?试了两个,最终 "nuxtjs" 才是 Vercel 能正确识别的值。这种文档里不太明确的细节,只能靠试。
SQLite 的一小段弯路
@nuxt/content v3 底层用 better-sqlite3 做查询引擎。部署时尝试过手动配置 native SQLite connector,折腾了几个 commit 之后发现——什么都不配,让 Vercel 自动处理就好了。过度配置有时候比不配更糟。
最终的 vercel.json 极其简洁:
{
"installCommand": "pnpm install",
"framework": "nuxtjs"
}
加上 nuxt.config.ts 里的 nitro: { preset: 'vercel' },搞定。
内容缓存大坑:客户端 404
上线后遇到的最阴的 bug。
现象
部署新文章后,直接访问 URL 没问题,但通过客户端导航(点链接跳转)会 404。刷新页面又正常了。
排查过程
一开始以为是 CDN 缓存的问题,给 content API 路由加了 no-cache 的 routeRules——没用。
继续挖,发现 @nuxt/content v3 会把内容数据库缓存到浏览器的 localStorage。当你部署新内容后,用户浏览器里的旧缓存不会自动失效。客户端导航时,Nuxt 用的是 localStorage 里的旧数据,自然找不到新文章。
解决方案
写了一个客户端插件 content-cache.client.ts:
export default defineNuxtPlugin(() => {
const { buildTimestamp } = useRuntimeConfig().public
if (!buildTimestamp) return
const cacheKey = 'content_build_id'
const stored = localStorage.getItem(cacheKey)
if (stored !== buildTimestamp) {
// 清除所有 content 相关的 localStorage 缓存
Object.keys(localStorage)
.filter(k => k.startsWith('content_collection_')
|| k.startsWith('content_checksum_'))
.forEach(k => localStorage.removeItem(k))
localStorage.setItem(cacheKey, buildTimestamp as string)
}
})
原理很简单:每次构建生成一个时间戳,客户端启动时对比时间戳,不一致就清缓存。
这个坑的教训:SSR 应用里,客户端缓存是容易被忽视的一环。服务端渲染正确不代表客户端导航也正确——两者走的是完全不同的数据路径。
与 Claude Code 结对编程
这个项目有个特别之处:绝大多数 commit 都是与 Claude Code 协作完成的。翻一下 git log,几乎每个 commit 都带着 Co-Authored-By: Claude 的标记。
Claude Code 做了什么
- 脚手架搭建:项目初始化、配置文件、组件模板,这些重复性工作交给 AI 很合适
- Bug 排查:Tailwind v4/v3 语法差异、color-mode 版本不存在——这些问题 AI 能快速定位并修复
- 部署调试:Vercel 配置的多次尝试,AI 可以不厌其烦地改了试、试了改
- 缓存问题诊断:从怀疑 CDN 到定位 localStorage,再到写出修复插件,整个排查链路 AI 都参与了
我做了什么
- 设计决策:选什么框架、用什么字体、背景要不要格子纹理——这些审美和产品判断是人做的
- 需求定义:告诉 AI "我要一个按年份分组的博客列表",而不是让它猜
- 质量把关:AI 写的代码不是直接合并,每一步都要看过、确认符合预期
- 方向调整:部署方式从 SSG 切回 SSR,是我做的决定
真实感受
用 Claude Code 的感觉不像是在用一个工具,更像是有一个不知疲倦的搭档。它不会嫌你改需求,不会因为反复调试配置而烦躁。
但它也不是万能的。AI 会犯错(比如用了不存在的依赖版本),会做出不够优雅的实现,有时候需要你明确纠正。人的角色始终是决策者和审美把关者,AI 负责执行和加速。
一个人加一个 AI,一天搭完博客核心功能,这在以前是不太敢想的效率。
项目最终结构
botao.site/
├── app/
│ ├── components/ # AppHeader, BlogCard, TagBadge, ThemeToggle
│ ├── pages/ # 6 个页面路由
│ ├── plugins/ # content-cache 缓存清理
│ ├── layouts/ # 默认布局
│ └── assets/css/ # CSS 变量 + 全局样式
├── content/blog/ # Markdown 文章
├── nuxt.config.ts # 8 个 Nuxt 模块
├── tailwind.config.ts # 字体 + typography 插件
└── vercel.json # 部署配置(就两行)
整个技术栈的核心依赖不超过 8 个。没有状态管理库,没有 UI 组件库,没有过度工程。够用就好。
总结
回头看这个项目,几个感触:
- 核心功能一天搭完,后续全是细节。真正花时间的不是写页面,是调 Vercel 部署、修缓存 bug、打磨字体和间距
- 少即是多。博客不需要花哨的功能,Markdown + 好看的排版 + 暗色模式就够了
- 踩坑是学习的最快路径。Tailwind 版本差异、Vercel framework 字段、localStorage 缓存——这些不踩到就不会知道
- AI 辅助开发是真的提效。但前提是你知道自己要什么,AI 负责帮你更快地到达那里
如果你也想搭一个类似的博客,Nuxt + Content + Vercel 是一个很省心的组合。希望这篇流水账能帮到你。