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 里创建 user、session、account、verification 这几张表。
在 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 服务。对于个人博客这类项目,这套组合在开发体验、运行成本和全球访问速度三个维度上都接近最优解。