从零搭建个人博客:botao.site 建站全记录

·14 分钟阅读

为什么要搭一个自己的博客

写了这么久代码,一直没有一个自己的地方沉淀东西(懒)。 我想要一个足够简单的博客:用 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 pushvercel 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(顶部导航)、AppFooterBlogCard(文章卡片)、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 组件库,没有过度工程。够用就好。


总结

回头看这个项目,几个感触:

  1. 核心功能一天搭完,后续全是细节。真正花时间的不是写页面,是调 Vercel 部署、修缓存 bug、打磨字体和间距
  2. 少即是多。博客不需要花哨的功能,Markdown + 好看的排版 + 暗色模式就够了
  3. 踩坑是学习的最快路径。Tailwind 版本差异、Vercel framework 字段、localStorage 缓存——这些不踩到就不会知道
  4. AI 辅助开发是真的提效。但前提是你知道自己要什么,AI 负责帮你更快地到达那里

如果你也想搭一个类似的博客,Nuxt + Content + Vercel 是一个很省心的组合。希望这篇流水账能帮到你。