Jun 13, 2026 · 28 views

用 Cloudflare + TanStack 构建博客系统

本文将带你从零搭建一个部署在 Cloudflare Workers 上的全栈个人博客,数据存储、图片托管、缓存全部使用 Cloudflare 原生服务,前端框架采用 TanStack Start + React 19。

本文将带你从零搭建一个部署在 Cloudflare Workers 上的全栈个人博客,数据存储、图片托管、缓存全部使用 Cloudflare 原生服务,前端框架采用 TanStack Start + React 19。完整代码见 cloudflare-blog

为什么选择这套技术栈?

自建博客方案很多,但大多数要么需要额外的服务器,要么依赖第三方数据库,要么冷启动慢、维护成本高。这套方案的核心优势是:

  • 全部跑在 Cloudflare 边缘网络上:Workers 无服务器计算 + D1 SQLite 数据库 + R2 对象存储 + KV 键值缓存,四个服务形成闭环,不需要额外的云服务账号。
  • 免费额度极为慷慨:个人博客的流量完全在 Cloudflare 免费层内运行。
  • TanStack Start 提供现代化的全栈开发体验:文件路由、服务器函数(Server Functions)、SSR,和 Next.js 体验接近但更轻量。

整体架构

浏览器
  │
  ▼
Cloudflare Workers (TanStack Start SSR)
  ├── 公开页面   /         首页、/archive 文章列表、/posts/$slug 文章详情
  ├── 管理后台   /admin    编辑文章、管理图片、分类标签
  └── API 路由  /api/auth、/api/media
         │
         ├── Cloudflare D1  (SQLite 关系型数据库,Drizzle ORM)
         ├── Cloudflare R2  (图片/附件对象存储)
         └── Cloudflare KV  (公开文章读穿缓存)

公开页面的读请求会先查 KV 缓存,命中则直接返回,未命中才查 D1 并回填缓存。管理员写操作后会主动让缓存失效(版本号递增),下次读取自动从 D1 重建。


项目初始化

创建项目

使用 create-cloudflare 脚手架,选择 TanStack Start 模板:

npm create cloudflare@latest cloudflare-blog -- --framework=tanstack-start
cd cloudflare-blog
npm install

关键依赖清单(package.json):

{
  "dependencies": {
    "@cloudflare/vite-plugin": "^1.26.0",
    "@tanstack/react-start": "latest",
    "@tanstack/react-router": "latest",
    "better-auth": "^1.6.16",
    "drizzle-orm": "^0.45.2",
    "pinyin-pro": "^3.28.1",
    "@blocknote/react": "^0.51.4",
    "tailwindcss": "^4.1.18"
  }
}

Vite 配置

vite.config.ts 是整个项目的核心胶水——把 Cloudflare 插件、TanStack Start 插件、Tailwind 串在一起:

// vite.config.ts
import { defineConfig } from 'vite'
import { tanstackStart } from '@tanstack/react-start/plugin/vite'
import { cloudflare } from '@cloudflare/vite-plugin'
import tailwindcss from '@tailwindcss/vite'
import viteReact from '@vitejs/plugin-react'

export default defineConfig({
  resolve: { tsconfigPaths: true },
  plugins: [
    cloudflare({ viteEnvironment: { name: 'ssr' } }),
    tailwindcss(),
    tanstackStart(),
    viteReact(),
  ],
})

cloudflare({ viteEnvironment: { name: 'ssr' } }) 这一行让 Vite 在本地开发时模拟 Workers 运行时,包括 D1/R2/KV 绑定,无需真正部署就能调试。

Wrangler 配置

wrangler.jsonc 声明所有 Cloudflare 资源绑定:

{
  "name": "cloudflare-personal-blog",
  "compatibility_date": "2026-06-06",
  "compatibility_flags": ["nodejs_compat"],
  "main": "@tanstack/react-start/server-entry",   // 入口由 TanStack 提供
  "d1_databases": [
    {
      "binding": "DB",
      "database_name": "cloudflare-personal-blog-db",
      "database_id": "<你的 D1 数据库 ID>",
      "migrations_dir": "migrations"
    }
  ],
  "kv_namespaces": [
    { "binding": "KV", "id": "<你的 KV 命名空间 ID>" }
  ],
  "r2_buckets": [
    { "binding": "MEDIA_BUCKET", "bucket_name": "cloudflare-personal-blog-media" }
  ]
}

注意 "main" 字段直接指向 @tanstack/react-start/server-entry,框架负责处理 Workers Fetch 事件,你不需要手写 Worker 入口文件。


数据库:Cloudflare D1 + Drizzle ORM

为什么选 D1?

D1 是 Cloudflare 托管的 SQLite 数据库,查询在离用户最近的边缘节点执行。对于个人博客这类读多写少的场景,它的响应速度极快且几乎零成本。

创建 D1 数据库

npx wrangler d1 create cloudflare-personal-blog-db

执行后把输出的 database_id 填入 wrangler.jsonc

Schema 设计

src/data/schema.ts 用 Drizzle 的 SQLite 类型定义所有表:

import { sqliteTable, text, integer } from 'drizzle-orm/sqlite-core'

// 文章表
export const posts = sqliteTable('posts', {
  id: integer('id').primaryKey({ autoIncrement: true }),
  title: text('title').notNull(),
  slug: text('slug').notNull().unique(),
  content: text('content'),          // Markdown 正文
  contentBlocks: text('content_blocks'), // BlockNote 块 JSON(用于编辑器回显)
  coverImage: text('cover_image'),
  status: text('status').notNull().default('draft'), // 'draft' | 'published'
  publishedAt: text('published_at'),
  createdAt: text('created_at').notNull(),
  updatedAt: text('updated_at').notNull(),
})

// 媒体文件表(R2 元数据)
export const media = sqliteTable('media', {
  id: integer('id').primaryKey({ autoIncrement: true }),
  key: text('key').notNull().unique(),   // R2 对象 key
  filename: text('filename').notNull(),
  mimeType: text('mime_type').notNull(),
  size: integer('size').notNull(),
  url: text('url').notNull(),
  createdAt: text('created_at').notNull(),
})

// 标签、分类、多对多关联表...
export const tags = sqliteTable('tags', { /* ... */ })
export const categories = sqliteTable('categories', { /* ... */ })
export const postTags = sqliteTable('post_tags', { /* ... */ })
export const postCategories = sqliteTable('post_categories', { /* ... */ })

Drizzle 配置与迁移

// drizzle.config.ts
import { defineConfig } from 'drizzle-kit'

export default defineConfig({
  dialect: 'sqlite',
  schema: './src/data/schema.ts',
  out: './migrations',
  casing: 'snake_case',
})

常用数据库命令:

# 根据 schema 生成迁移 SQL
npm run db:generate

# 应用迁移到本地 D1(开发)
npm run db:migrate:local

# 应用迁移到线上 D1
npm run db:migrate:remote

# 导入种子数据(本地开发)
npm run db:seed:local

# 打开 Drizzle Studio 可视化查看数据
npm run db:studio

在 Worker 中使用 D1

在 TanStack Start 的服务器函数里,通过 getRequestContext() 拿到 Worker 绑定:

// src/data/db.ts
import { drizzle } from 'drizzle-orm/d1'
import { getRequestContext } from '@cloudflare/next-on-pages'
import * as schema from './schema'

export function getDb() {
  const { env } = getRequestContext()
  return drizzle(env.DB, { schema })
}

然后在服务器函数里直接查询:

// src/features/public-posts/loaders.ts
import { createServerFn } from '@tanstack/react-start'
import { getDb } from '#/data/db'
import { posts } from '#/data/schema'
import { eq } from 'drizzle-orm'

export const getPostBySlug = createServerFn({ method: 'GET' })
  .validator((slug: string) => slug)
  .handler(async ({ data: slug }) => {
    const db = getDb()
    const post = await db.query.posts.findFirst({
      where: eq(posts.slug, slug),
    })
    return post ?? null
  })

路由系统:TanStack Router 文件路由

TanStack Start 基于 TanStack Router,支持文件路由——目录结构即路由结构。

src/routes/
├── __root.tsx          根布局(HTML、主题、导航)
├── index.tsx           / 首页
├── about.tsx           /about
├── archive.tsx         /archive 文章列表(分页、分类、标签、搜索)
├── posts.$slug.tsx     /posts/$slug 文章详情
├── login.tsx           /login
├── admin/
│   ├── index.tsx       /admin 后台首页
│   ├── posts.tsx       /admin/posts 文章列表
│   ├── posts.new.tsx   /admin/posts/new 新建文章
│   ├── posts.$id.tsx   /admin/posts/$id 编辑文章
│   ├── media.tsx       /admin/media 媒体库
│   └── taxonomy.tsx    /admin/taxonomy 分类标签
└── api/
    ├── auth.$.ts       /api/auth/* Better Auth 处理器
    └── media.ts        /api/media 图片代理

每个路由文件可以导出 loader(SSR 数据获取)和默认组件:

// src/routes/posts.$slug.tsx
import { createFileRoute } from '@tanstack/react-router'
import { getPostBySlug } from '#/features/public-posts/loaders'

export const Route = createFileRoute('/posts/$slug')({
  loader: async ({ params }) => {
    const post = await getPostBySlug({ data: params.slug })
    if (!post) throw new Error('Post not found')
    return { post }
  },
  component: PostPage,
})

function PostPage() {
  const { post } = Route.useLoaderData()
  return (
    <article>
      <h1>{post.title}</h1>
      {/* 渲染 Markdown */}
    </article>
  )
}

KV 缓存层

读公开文章是博客最高频的操作。每次都查 D1 虽然不慢,但加一层 KV 缓存可以进一步降低延迟并减少 D1 查询消耗。

缓存策略

使用版本号而非直接删除缓存键,好处是原子性强——只需递增版本号,旧缓存自然失效,不需要枚举所有相关键逐一删除。

// src/data/posts-cache.server.ts
import { getRequestContext } from '@cloudflare/next-on-pages'

const VERSION_KEY = 'cache:version'

async function getVersion(kv: KVNamespace): Promise<number> {
  const v = await kv.get(VERSION_KEY)
  return v ? parseInt(v) : 0
}

export async function getCachedPost(slug: string) {
  const { env } = getRequestContext()
  const kv = env.KV
  const version = await getVersion(kv)
  const key = `post:v${version}:${slug}`
  const cached = await kv.get(key, 'json')
  return cached
}

export async function setCachedPost(slug: string, data: unknown) {
  const { env } = getRequestContext()
  const kv = env.KV
  const version = await getVersion(kv)
  const key = `post:v${version}:${slug}`
  await kv.put(key, JSON.stringify(data), { expirationTtl: 86400 }) // 24 小时
}

// 管理员写操作后调用此函数让全部缓存失效
export async function invalidateCache() {
  const { env } = getRequestContext()
  const kv = env.KV
  const version = await getVersion(kv)
  await kv.put(VERSION_KEY, String(version + 1))
}

在 Loader 中使用读穿(read-through)模式:

export const getPostBySlug = createServerFn({ method: 'GET' })
  .validator((slug: string) => slug)
  .handler(async ({ data: slug }) => {
    // 先查缓存
    const cached = await getCachedPost(slug)
    if (cached) return cached

    // 缓存未命中,查 D1
    const db = getDb()
    const post = await db.query.posts.findFirst({
      where: eq(posts.slug, slug),
    })
    if (!post) return null

    // 回填缓存
    await setCachedPost(slug, post)
    return post
  })

图片存储:Cloudflare R2

上传流程

管理员在文章编辑器中插入图片时,前端将文件通过 Server Function 上传到 R2,同时在 D1 的 media 表写入元数据。

// src/features/admin/media.ts
import { createServerFn } from '@tanstack/react-start'
import { getRequestContext } from '@cloudflare/next-on-pages'
import { getDb } from '#/data/db'
import { media } from '#/data/schema'

export const uploadMedia = createServerFn({ method: 'POST' })
  .handler(async ({ request }) => {
    const { env } = getRequestContext()
    const formData = await request.formData()
    const file = formData.get('file') as File

    // 生成 R2 key(按日期分目录)
    const date = new Date().toISOString().slice(0, 10)
    const key = `uploads/${date}/${crypto.randomUUID()}-${file.name}`

    // 上传到 R2
    await env.MEDIA_BUCKET.put(key, file.stream(), {
      httpMetadata: { contentType: file.type },
    })

    // 写元数据到 D1
    const db = getDb()
    await db.insert(media).values({
      key,
      filename: file.name,
      mimeType: file.type,
      size: file.size,
      url: `/api/media?key=${encodeURIComponent(key)}`,
      createdAt: new Date().toISOString(),
    })

    return { url: `/api/media?key=${encodeURIComponent(key)}` }
  })

图片代理路由

R2 Bucket 不对外直接暴露,通过 API 路由代理访问:

// src/routes/api/media.ts
import { createAPIFileRoute } from '@tanstack/react-start/api'
import { getRequestContext } from '@cloudflare/next-on-pages'

export const APIRoute = createAPIFileRoute('/api/media')({
  GET: async ({ request }) => {
    const { env } = getRequestContext()
    const url = new URL(request.url)
    const key = url.searchParams.get('key')

    if (!key) return new Response('Missing key', { status: 400 })

    const object = await env.MEDIA_BUCKET.get(key)
    if (!object) return new Response('Not found', { status: 404 })

    return new Response(object.body, {
      headers: {
        'Content-Type': object.httpMetadata?.contentType ?? 'application/octet-stream',
        'Cache-Control': 'public, max-age=31536000, immutable',
      },
    })
  },
})

管理员认证:Better Auth + GitHub OAuth

为什么不用 Cloudflare Access?

Cloudflare Access 需要额外配置域名,而且对 API 路由的保护也要额外处理。Better Auth 直接运行在 Worker 里,配合邮箱白名单,实现简单且完全可控。

配置 Better Auth

// src/features/auth/auth.server.ts
import { betterAuth } from 'better-auth'
import { drizzleAdapter } from 'better-auth/adapters/drizzle'
import { getDb } from '#/data/db'
import { getRequestContext } from '@cloudflare/next-on-pages'

export function getAuth() {
  const { env } = getRequestContext()
  return betterAuth({
    database: drizzleAdapter(getDb(), { provider: 'sqlite' }),
    socialProviders: {
      github: {
        clientId: env.GITHUB_CLIENT_ID,
        clientSecret: env.GITHUB_CLIENT_SECRET,
      },
    },
    // 邮箱白名单:只有列表中的 GitHub 邮箱可以登录
    trustedOrigins: [env.BETTER_AUTH_URL],
  })
}

Better Auth 会自动在 D1 里创建 usersessionaccountverification 这几张表。

在 API 路由中挂载

// src/routes/api/auth.$.ts
import { createAPIFileRoute } from '@tanstack/react-start/api'
import { getAuth } from '#/features/auth/auth.server'

export const APIRoute = createAPIFileRoute('/api/auth/$')({
  GET: ({ request }) => getAuth().handler(request),
  POST: ({ request }) => getAuth().handler(request),
})

管理员路由保护

在管理后台的根 Loader 中检查 Session,未登录则重定向:

// src/routes/admin/index.tsx
import { createFileRoute, redirect } from '@tanstack/react-router'
import { getAuth } from '#/features/auth/auth.server'

export const Route = createFileRoute('/admin/')({
  loader: async ({ request }) => {
    const auth = getAuth()
    const session = await auth.api.getSession({ headers: request.headers })
    if (!session) throw redirect({ to: '/login' })
    return { user: session.user }
  },
})

配置生产环境 Secrets

wrangler secret put BETTER_AUTH_SECRET
wrangler secret put BETTER_AUTH_URL        # 例如 https://yourblog.com
wrangler secret put GITHUB_CLIENT_ID
wrangler secret put GITHUB_CLIENT_SECRET
wrangler secret put ADMIN_EMAILS           # alice@example.com,bob@example.com

文章编辑器:BlockNote

BlockNote 是一个基于 ProseMirror 的块编辑器,支持拖拽、斜杠命令、内联图片插入等功能,且输出 Markdown 和块 JSON 两种格式。

// src/components/admin/PostEditor.tsx
import { BlockNoteView } from '@blocknote/mantine'
import { useCreateBlockNote } from '@blocknote/react'
import { Block } from '@blocknote/core'

interface PostEditorProps {
  initialBlocks?: Block[]
  onChange: (markdown: string, blocks: Block[]) => void
}

export function PostEditor({ initialBlocks, onChange }: PostEditorProps) {
  const editor = useCreateBlockNote({
    initialContent: initialBlocks,
    uploadFile: async (file) => {
      // 调用 Server Function 上传到 R2
      const form = new FormData()
      form.append('file', file)
      const res = await fetch('/_serverFn/uploadMedia', { method: 'POST', body: form })
      const { url } = await res.json()
      return url
    },
  })

  // 内容变化时同步输出 Markdown + 块 JSON
  editor.onChange(async () => {
    const markdown = await editor.blocksToMarkdownLossy(editor.document)
    onChange(markdown, editor.document)
  })

  return <BlockNoteView editor={editor} />
}

文章保存时同时存储 content(Markdown,供公开页面渲染)和 contentBlocks(JSON,供编辑器回显)。


中文标题转拼音 Slug

博客文章标题往往是中文,需要自动生成 URL 友好的 Slug。项目使用 pinyin-pro 库实现:

// src/lib/slug.ts
import { pinyin } from 'pinyin-pro'

export function generateSlug(title: string): string {
  // 将中文转为拼音,英文保留
  const pinyinStr = pinyin(title, {
    toneType: 'none',    // 不带声调
    separator: '-',
    nonZh: 'consecutive', // 非中文字符连续输出
  })

  return pinyinStr
    .toLowerCase()
    .replace(/[^a-z0-9-]+/g, '-') // 非字母数字替换为连字符
    .replace(/-+/g, '-')           // 合并连续连字符
    .replace(/^-|-$/g, '')         // 去掉首尾连字符
}

// 示例:
// "用 Cloudflare 构建博客" → "yong-cloudflare-gou-jian-bo-ke"

本地开发

# 安装依赖
npm install

# 应用本地 D1 迁移
npm run db:migrate:local

# 导入种子数据
npm run db:seed:local

# 启动开发服务器(含 Workers 模拟环境)
npm run dev
# → http://localhost:3000

本地开发时,@cloudflare/vite-plugin 会启动一个内嵌的 workerd 实例,完整模拟 D1、R2、KV 绑定,与线上行为高度一致。


部署到生产

# 登录 Wrangler
npx wrangler login

# 应用线上 D1 迁移
npm run db:migrate:remote

# 构建并部署
npm run deploy

部署命令本质上是 vite build && wrangler deploy。Wrangler 会自动打包 Worker 产物并上传到 Cloudflare 网络,几秒内全球生效。


项目目录结构总览

cloudflare-blog/
├── src/
│   ├── routes/               文件路由(公开页面 + 管理后台 + API)
│   ├── features/
│   │   ├── public-posts/     公开页面 Loader 和服务器函数
│   │   ├── admin/            管理后台服务器函数(文章写入、媒体上传)
│   │   └── auth/             Session 检查
│   ├── data/
│   │   ├── schema.ts         Drizzle 数据库 Schema
│   │   ├── db.ts             DB 工厂
│   │   └── posts-cache.server.ts  KV 缓存工具
│   ├── components/           公开 UI 组件和管理后台 UI 组件
│   └── lib/                  工具函数(slug 生成等)
├── migrations/               D1 迁移 SQL 文件
├── seed/local.sql            本地开发种子数据
├── wrangler.jsonc            Cloudflare 资源绑定配置
├── drizzle.config.ts         Drizzle Kit 配置
└── vite.config.ts            Vite + Cloudflare + TanStack 插件

小结

这套架构的核心思路是让 Cloudflare 平台承载尽可能多的基础设施职责

  • 计算 → Cloudflare Workers(边缘 SSR)
  • 数据 → Cloudflare D1(SQLite,Drizzle ORM 类型安全访问)
  • 存储 → Cloudflare R2(图片/媒体,成本极低)
  • 缓存 → Cloudflare KV(公开文章读穿缓存,版本号失效策略)
  • 认证 → Better Auth(GitHub OAuth + 邮箱白名单,完全运行在 Worker 内)

TanStack Start 的文件路由和 Server Functions 让前后端代码自然共存,不需要维护独立的 API 服务。对于个人博客这类项目,这套组合在开发体验、运行成本和全球访问速度三个维度上都接近最优解。

Comments

Comment anonymously, or to comment with your profile.

0/5000

Loading comments...