Next.js + Cloudflare 全栈实践:从零搭建一个技术博客
最近完成了一个技术博客的搭建,技术栈选择了 Next.js (App Router) + Cloudflare Pages + D1 + R2。整个过程踩了不少坑,也积累了一些有价值的实践经验,在这里做一个完整的技术总结。
为什么选择这套技术栈
在动手之前,我对比了几个方案:
| 方案 | 优点 | 缺点 |
|---|---|---|
| Vercel + PlanetScale | 生态成熟,部署简单 | 数据库收费,冷启动慢 |
| VPS + Docker | 完全可控 | 需要运维,成本高 |
| Cloudflare Pages + D1 | 免费额度大,全球边缘节点,速度快 | 生态较新,文档不够完善 |
最终选择 Cloudflare 的理由很简单:免费额度足够个人博客使用,D1 数据库、R2 对象存储、Pages 托管全部有免费额度。更关键的是,所有请求都在离用户最近的边缘节点处理,响应速度非常快。
整体架构
┌─────────────────────────────────────────────────┐
│ Cloudflare Pages │
│ ┌───────────────────────────────────────────┐ │
│ │ Next.js (Edge Runtime) │ │
│ │ │ │
│ │ Server Components ──fetch──▶ API Routes │ │
│ │ │ │ │
│ │ Drizzle ORM │ │
│ │ │ │ │
│ └───────────────────────────────┼───────────┘ │
│ │ │
│ ┌─────▼─────┐ │
│ │ D1 (SQLite) │ │
│ │ + FTS5 │ │
│ └───────────┘ │
└─────────────────────────────────────────────────┘
┌──────────┐ S3 API ┌──────────┐
│ CLI 工具 │ ────────────────────▶│ R2 存储 │
│ (本地运行) │ │ (图片) │
└──────────┘ └──────────┘
核心设计原则:所有页面和 API 都运行在边缘,数据库查询通过 Drizzle ORM 操作 D1,图片通过独立的 CLI 工具上传到 R2。
关键技术实现
1. Edge Runtime 下的数据获取模式
这是整个项目最核心的架构决策。在 Cloudflare Pages 上跑 Next.js,有一个重要的限制:Server Components 无法直接访问 D1 数据库绑定,只有 Route Handlers 才能通过 getRequestContext() 拿到 D1 实例。
所以采用了"自己请求自己"的模式:
// src/app/page.tsx — Server Component
export const runtime = "edge";
export const dynamic = "force-dynamic";
async function getPosts() {
const res = await fetch(`${process.env.SITE_URL}/api/posts?limit=10`, {
next: { revalidate: 300 }, // 5 分钟缓存
});
if (!res.ok) return [];
const data = await res.json();
return data.posts;
}
// src/app/api/posts/route.ts — Route Handler
export const runtime = "edge";
export async function GET(request: NextRequest) {
// 只有在 Route Handler 里才能获取 D1 绑定
const { env } = getRequestContext();
const db = drizzle(env.DB, { schema });
const posts = await db
.select()
.from(schema.posts)
.orderBy(desc(schema.posts.date))
.limit(limit);
// ...
}
有一个关键的坑:getRequestContext() 必须在请求处理函数内部调用,绝对不能放在模块顶层。D1 绑定是请求级别的,模块级别拿不到。
2. D1 数据库 + Drizzle ORM
D1 本质上是跑在边缘的 SQLite。用 Drizzle 做 ORM 层体验很好,类型安全且轻量。
Schema 设计上有个值得一提的点 —— 所有字段都用 text 类型:
// src/db/schema.ts
export const posts = sqliteTable("posts", {
id: text("id").primaryKey(),
slug: text("slug").notNull().unique(),
title: text("title").notNull(),
coverImage: text("cover_image"),
date: text("date").notNull(),
// ...
});
这是 SQLite 的特性决定的:SQLite 是弱类型的,日期存 ISO 字符串、ID 存 UUID 都是惯用做法。主键使用 crypto.randomUUID() 在边缘运行时生成(需要 nodejs_compat 兼容标志)。
关联表设计了级联删除,删文章自动清理标签关联:
export const postTags = sqliteTable("post_tags", {
postId: text("post_id")
.notNull()
.references(() => posts.id, { onDelete: "cascade" }),
tagId: text("tag_id")
.notNull()
.references(() => tags.id, { onDelete: "cascade" }),
});
3. FTS5 全文搜索
D1 支持 SQLite FTS5 扩展,这是一个很少被提及但非常实用的特性。我在初始迁移中配置了外部内容 FTS5 表:
-- drizzle/0000_init.sql
CREATE VIRTUAL TABLE IF NOT EXISTS search_index USING fts5(
slug, title, description, content,
content='posts',
content_rowid='rowid'
);
content='posts' 表示 FTS5 索引不单独存储文本,而是引用 posts 表的数据,节省存储空间。
然后用三个触发器保持索引同步:
-- 插入时同步
CREATE TRIGGER posts_ai AFTER INSERT ON posts BEGIN
INSERT INTO search_index(rowid, slug, title, description, content)
VALUES (NEW.rowid, NEW.slug, NEW.title, NEW.description, NEW.content);
END;
-- 删除时同步(注意 FTS5 的特殊删除语法)
CREATE TRIGGER posts_ad AFTER DELETE ON posts BEGIN
INSERT INTO search_index(search_index, rowid, slug, title, description, content)
VALUES ('delete', OLD.rowid, OLD.slug, OLD.title, OLD.description, OLD.content);
END;
-- 更新 = 先删后增
CREATE TRIGGER posts_au AFTER UPDATE ON posts BEGIN
INSERT INTO search_index(search_index, rowid, slug, title, description, content)
VALUES ('delete', OLD.rowid, OLD.slug, OLD.title, OLD.description, OLD.content);
INSERT INTO search_index(rowid, slug, title, description, content)
VALUES (NEW.rowid, NEW.slug, NEW.title, NEW.description, NEW.content);
END;
注意 FTS5 的删除语法非常特殊:你需要 INSERT INTO search_index(search_index, ...) VALUES ('delete', ...),往一个和表同名的列里插入字符串 'delete'。这是 SQLite FTS5 的标准用法,但第一次看到会觉得很奇怪。
前端搜索用 Cmd+K / Ctrl+K 快捷键触发,配合 300ms 防抖:
// src/hooks/useSearch.tsx
useEffect(() => {
const handler = (e: KeyboardEvent) => {
if ((e.metaKey || e.ctrlKey) && e.key === "k") {
e.preventDefault();
setIsOpen((prev) => !prev);
}
};
document.addEventListener("keydown", handler);
return () => document.removeEventListener("keydown", handler);
}, []);
4. R2 图片存储 + CLI 发布工具
图片处理是博客系统的一个核心环节。我的方案是:不在 Web 端处理图片上传,而是通过本地 CLI 工具完成。
工作流程:
Markdown 文件 (含本地图片路径)
│
▼
CLI 解析 frontmatter + 提取图片路径
│
▼
上传图片到 R2 (S3 兼容 API)
│
▼
替换 Markdown 中的本地路径为 R2 URL
│
▼
POST 到博客 API,存入 D1
CLI 使用 @aws-sdk/client-s3 连接 R2,因为 R2 完全兼容 S3 协议:
// cli/src/uploader.ts
this.client = new S3Client({
region: "auto",
endpoint: `https://${config.accountId}.r2.cloudflarestorage.com`,
credentials: {
accessKeyId: config.accessKeyId,
secretAccessKey: config.secretAccessKey,
},
});
上传前会用 HeadObjectCommand 检查文件是否已存在,避免重复上传:
async uploadImage(image: LocalImage, slug: string, force = false) {
const r2Key = `images/posts/${slug}/${image.filename}`;
if (!force) {
const exists = await this.fileExists(r2Key);
if (exists) return { /* skipped */ };
}
await this.client.send(new PutObjectCommand({
Bucket: this.bucket,
Key: r2Key,
Body: content,
ContentType: this.getContentType(image.filename),
}));
}
coverImage 也会被处理 —— 无论是在 Markdown 正文中引用的图片,还是 frontmatter 中单独指定的封面图,都会上传到 R2 并替换路径。
5. 暗黑模式的无闪烁方案
暗黑模式看似简单,但处理不好会在页面加载时出现"闪白"(FOUC)。解决方案是在 <head> 中注入同步执行的脚本:
// src/app/layout.tsx
<script
dangerouslySetInnerHTML={{
__html: `(function(){
var d=document.documentElement;
var t=localStorage.getItem('theme');
if(t==='dark'||(t!=='light'&&
window.matchMedia('(prefers-color-scheme:dark)').matches)){
d.classList.add('dark')
}
})()`,
}}
/>
这段 IIFE 在 React 水合之前同步执行,直接操作 DOM。配合 <html> 标签上的 suppressHydrationWarning,避免水合不匹配的警告。
6. 中文适配的细节
作为中文博客,有些细节需要特殊处理:
阅读时间估算:中文不能按单词数算,得按字符数。中文平均阅读速度约 400 字/分钟:
const readingTime = Math.max(3, Math.ceil(post.content.length / 400));
HTML lang 属性设置为 zh-CN,对 SEO 和无障碍访问都有帮助。
踩坑记录
wrangler.toml 中的 nodejs_compat
如果忘了加 compatibility_flags = ["nodejs_compat"],很多 Node.js API(crypto、Buffer 等)在 Workers 运行时不可用,Drizzle ORM 会直接报错。
@cloudflare/next-on-pages 的版本兼容
@cloudflare/next-on-pages 目前对 Next.js 14 的支持最稳定。package.json 中需要设置 overrides 保证版本一致:
{
"overrides": {
"@cloudflare/next-on-pages": {
"next": "$next"
}
}
}
Drizzle 不支持 FTS5
Drizzle ORM 没有内置对 SQLite FTS5 虚拟表的支持,所以 FTS5 的创建和触发器都得写在原始 SQL 迁移文件中。查询时也需要用 db.run(sql\...`)` 执行原生 SQL。
构建输出目录
wrangler.toml 中 pages_build_output_dir = ".vercel/output/static" 看起来很奇怪 —— 明明部署到 Cloudflare,为什么输出目录叫 .vercel?这是因为 @cloudflare/next-on-pages 适配器会先生成 Vercel 格式的输出,然后再转换为 Cloudflare Pages 兼容的格式。
总结
这套技术栈的体验总体是正面的:
- 性能好:边缘渲染 + SQLite 查询,响应很快
- 成本低:Cloudflare 免费额度完全够用
- 开发体验:Next.js App Router + Drizzle ORM 写起来很舒服
但也有不足:
- 生态不够成熟:
@cloudflare/next-on-pages的文档和社区资源相比 Vercel 少很多 - 调试困难:边缘运行时的一些限制在本地开发时不容易发现
- FTS5 支持有限:ORM 层面不支持,需要写原生 SQL
如果你也在考虑搭建个人博客,这套方案值得一试。全部代码都跑在边缘,不需要任何传统服务器,维护成本几乎为零。