从静态博客到带后台的在线 CMS:使用 Cloudflare Pages Functions + D1 部署个人博客

之前我做了一个纯静态个人 Geek 博客,最初只有 HTML、CSS、JavaScript 和一些文章静态文件。部署到 Cloudflare Pages 之后,访问速度很快,也不用维护服务器。

但是纯静态博客有一个明显缺点:每次修改文章、个人简介、联系方式、项目链接,都需要手动改文件,然后重新部署。

于是我把它升级成了一个带数据库、带后台管理页面的博客系统:

  • 前台:Cloudflare Pages
  • 后端 API:Cloudflare Pages Functions
  • 数据库:Cloudflare D1
  • 后台页面:/admin/
  • 管理内容:个人资料、联系方式、项目、文章、模板
  • 鉴权方式:Cloudflare Pages Secret 中保存 ADMIN_TOKEN

最终效果是:不需要自己买服务器,也不需要部署传统 Node.js 后端,就能拥有一个可以在线编辑内容的个人博客 CMS。

C:\Users\86182\Desktop\我的博客可以上瘾了

一、最终项目结构

改造后的项目结构如下:

text 复制代码
geek-blog/
├─ index.html
├─ post.html
├─ wrangler.toml
├─ schema.sql
├─ package.json
├─ assets/
│  ├─ main.js
│  ├─ render.js
│  └─ style.css
├─ admin/
│  ├─ index.html
│  ├─ admin.js
│  └─ admin.css
├─ functions/
│  └─ api/
│     ├─ _shared.js
│     ├─ site.js
│     ├─ posts/
│     │  └─ [slug].js
│     └─ admin/
│        ├─ _auth.js
│        ├─ login.js
│        ├─ profile.js
│        ├─ links.js
│        ├─ projects.js
│        ├─ template.js
│        └─ posts/
│           └─ index.js
└─ tests/
   ├─ api.test.mjs
   ├─ admin-api.test.mjs
   └─ render.test.mjs

其中:

  • index.html 是博客首页
  • post.html 是文章详情页
  • admin/ 是后台管理页面
  • functions/api/ 是 Cloudflare Pages Functions API
  • schema.sql 是 D1 数据库初始化脚本
  • wrangler.toml 是 Cloudflare 项目配置
  • assets/render.js 封装前台渲染逻辑

二、整体架构

整个系统的架构非常轻量:

text 复制代码
浏览器
  │
  ├─ 访问 /                 博客首页
  ├─ 访问 /post.html        文章详情页
  └─ 访问 /admin/           后台管理页

Cloudflare Pages
  │
  ├─ 托管 HTML/CSS/JS 静态资源
  └─ Pages Functions 提供 /api/* 接口

Cloudflare D1
  │
  └─ 保存文章、项目、联系方式、个人资料、模板配置

这个架构的好处是:

  • 不需要服务器
  • 不需要数据库运维
  • 不需要 Nginx
  • 不需要 PM2
  • 不需要传统后端部署
  • 前端、API、数据库都在 Cloudflare 上完成

三、创建 Cloudflare Pages 项目

首先使用 Wrangler 创建 Cloudflare Pages 项目:

bash 复制代码
npx wrangler pages project create geek-blog --production-branch main

创建成功后,Cloudflare 会分配一个默认域名,例如:

text 复制代码
https://geek-blog-aw8.pages.dev/

后面所有部署都会发布到这个 Pages 项目。

四、创建 D1 数据库

带后台的博客必须有地方存数据,这里使用 Cloudflare D1。

执行:

bash 复制代码
npx wrangler d1 create geek_blog_db

创建成功后,Wrangler 会返回一个 database_id,类似:

text 复制代码
database_id = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"

记住这个 ID,后面要写入 wrangler.toml

五、配置 wrangler.toml

项目根目录创建 wrangler.toml

toml 复制代码
name = "geek-blog"
pages_build_output_dir = "."
compatibility_date = "2026-06-29"

[[d1_databases]]
binding = "DB"
database_name = "geek_blog_db"
database_id = "你的-database-id"

这里最重要的是:

toml 复制代码
binding = "DB"

因为后端函数中会通过:

js 复制代码
env.DB

访问 D1 数据库。

例如:

js 复制代码
await env.DB.prepare("SELECT key, value FROM settings").all();

六、设计数据库表

数据库初始化脚本是 schema.sql

这套博客系统设计了四张表。

1. settings 表

sql 复制代码
CREATE TABLE IF NOT EXISTS settings (
  key TEXT PRIMARY KEY,
  value TEXT NOT NULL
);

settings 用来保存全局配置,例如:

  • 当前模板
  • 个人资料

因为这些数据结构比较灵活,所以直接用 JSON 字符串保存。

sql 复制代码
CREATE TABLE IF NOT EXISTS links (
  id INTEGER PRIMARY KEY AUTOINCREMENT,
  label TEXT NOT NULL,
  url TEXT NOT NULL,
  sort_order INTEGER NOT NULL DEFAULT 0
);

用于保存联系方式,例如:

  • GitHub
  • Email
  • CSDN
  • X/Twitter

3. projects 表

sql 复制代码
CREATE TABLE IF NOT EXISTS projects (
  id INTEGER PRIMARY KEY AUTOINCREMENT,
  name TEXT NOT NULL,
  description TEXT NOT NULL,
  url TEXT NOT NULL,
  tags TEXT NOT NULL DEFAULT '[]',
  sort_order INTEGER NOT NULL DEFAULT 0
);

用于保存项目展示信息。

其中 tags 使用 JSON 数组字符串保存,例如:

json 复制代码
["web", "typescript"]

4. posts 表

sql 复制代码
CREATE TABLE IF NOT EXISTS posts (
  id INTEGER PRIMARY KEY AUTOINCREMENT,
  slug TEXT NOT NULL UNIQUE,
  title TEXT NOT NULL,
  excerpt TEXT NOT NULL DEFAULT '',
  content TEXT NOT NULL,
  status TEXT NOT NULL DEFAULT 'published',
  published_at TEXT NOT NULL,
  updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
);

文章表中比较关键的是 slug 字段。

文章访问方式是:

text 复制代码
/post.html?slug=hello-world

而不是直接暴露数据库 id。

七、初始化数据库

创建好 schema.sql 后,执行:

bash 复制代码
npx wrangler d1 execute geek_blog_db --remote --file schema.sql

如果网络环境导致 --file 上传失败,也可以用多条 --command 分别执行 SQL。

例如:

bash 复制代码
npx wrangler d1 execute geek_blog_db --remote --command "SELECT 1"

如果返回成功,说明 D1 远程连接正常。

八、实现公开 API

前台需要两个公开接口:

text 复制代码
GET /api/site
GET /api/posts/:slug

1. 首页聚合接口

文件:

text 复制代码
functions/api/site.js

核心代码:

js 复制代码
export async function onRequestGet({ env }) {
  const settingsRows = await env.DB
    .prepare("SELECT key, value FROM settings")
    .all();

  const settings = Object.fromEntries(
    settingsRows.results.map((row) => [
      row.key,
      parseJson(row.value, row.value),
    ])
  );

  const links = await env.DB.prepare(
    "SELECT label, url FROM links ORDER BY sort_order, id"
  ).all();

  const projects = await env.DB.prepare(
    "SELECT name, description, url, tags FROM projects ORDER BY sort_order, id"
  ).all();

  const posts = await env.DB.prepare(
    "SELECT slug, title, excerpt, published_at FROM posts WHERE status = 'published' ORDER BY published_at DESC, id DESC"
  ).all();

  return json({
    template: settings.template || "terminal",
    profile: settings.profile || {},
    links: links.results,
    projects: projects.results.map(normalizeProject),
    posts: posts.results,
  });
}

这个接口一次性返回首页需要的所有数据:

  • 当前模板
  • 个人资料
  • 联系方式
  • 项目列表
  • 文章列表

这样前台只需要请求一次 /api/site

2. 文章详情接口

文件:

text 复制代码
functions/api/posts/[slug].js

代码:

js 复制代码
export async function onRequestGet({ env, params }) {
  const post = await env.DB.prepare(
    "SELECT slug, title, excerpt, content, published_at FROM posts WHERE slug = ? AND status = 'published'"
  )
    .bind(params.slug)
    .first();

  if (!post) {
    return json({ error: "Post not found" }, 404);
  }

  return json(post);
}

这里使用了 D1 参数绑定:

js 复制代码
.bind(params.slug)

比字符串拼接 SQL 更安全。

九、实现后台鉴权

后台不能直接公开写入接口,所以需要简单鉴权。

这里采用 ADMIN_TOKEN 方式。

后台登录时输入 token,前端保存到 sessionStorage,之后请求后台接口时带上:

http 复制代码
Authorization: Bearer <token>

鉴权代码放在:

text 复制代码
functions/api/admin/_auth.js

核心代码:

js 复制代码
export function isAuthorized(request, env) {
  const header = request.headers.get("authorization") || "";
  const token = header.replace(/^Bearer\s+/i, "");
  return Boolean(env.ADMIN_TOKEN && token && token === env.ADMIN_TOKEN);
}

export function requireAdmin(request, env) {
  if (!isAuthorized(request, env)) {
    return json({ error: "Unauthorized" }, 401);
  }
  return null;
}

登录接口:

text 复制代码
POST /api/admin/login

后台写操作都会调用 requireAdmin()

十、实现后台管理 API

后台 API 包括:

text 复制代码
POST /api/admin/login

GET /api/admin/profile
PUT /api/admin/profile

GET /api/admin/links
PUT /api/admin/links

GET /api/admin/projects
PUT /api/admin/projects

GET /api/admin/posts
POST /api/admin/posts
PUT /api/admin/posts
DELETE /api/admin/posts

GET /api/admin/template
PUT /api/admin/template

以文章管理为例:

js 复制代码
export async function onRequestPost({ request, env }) {
  const auth = requireAdmin(request, env);
  if (auth) return auth;

  const post = cleanPost(await readJson(request));
  if (!validatePost(post)) return json({ error: "Invalid post" }, 400);

  await env.DB.prepare(
    "INSERT INTO posts (slug, title, excerpt, content, status, published_at) VALUES (?, ?, ?, ?, ?, ?)"
  )
    .bind(
      post.slug,
      post.title,
      post.excerpt,
      post.content,
      post.status,
      post.published_at
    )
    .run();

  return json({ post });
}

更新文章:

js 复制代码
export async function onRequestPut({ request, env }) {
  const auth = requireAdmin(request, env);
  if (auth) return auth;

  const post = cleanPost(await readJson(request));
  if (!validatePost(post)) return json({ error: "Invalid post" }, 400);

  await env.DB.prepare(
    "UPDATE posts SET title = ?, excerpt = ?, content = ?, status = ?, published_at = ?, updated_at = CURRENT_TIMESTAMP WHERE slug = ?"
  )
    .bind(
      post.title,
      post.excerpt,
      post.content,
      post.status,
      post.published_at,
      post.slug
    )
    .run();

  return json({ post });
}

删除文章:

js 复制代码
export async function onRequestDelete({ request, env }) {
  const auth = requireAdmin(request, env);
  if (auth) return auth;

  const body = await readJson(request);
  const slug = String(body?.slug || "").trim();

  if (!slug) return json({ error: "Missing slug" }, 400);

  await env.DB
    .prepare("DELETE FROM posts WHERE slug = ?")
    .bind(slug)
    .run();

  return json({ ok: true });
}

十一、设置后台管理密钥

后台 token 不应该写死在代码里,而应该作为 Cloudflare Pages Secret 保存。

执行:

bash 复制代码
npx wrangler pages secret put ADMIN_TOKEN --project-name geek-blog

然后输入一个强密码,例如:

text 复制代码
your-strong-admin-token

注意:不要把真实 token 写进公开博客或 GitHub 仓库。

十二、实现后台页面

后台入口:

text 复制代码
/admin/

主要文件:

text 复制代码
admin/index.html
admin/admin.css
admin/admin.js

后台页面分为五个模块:

  • 资料
  • 联系方式
  • 项目
  • 文章
  • 模板

登录成功后,把 token 保存到 sessionStorage

js 复制代码
sessionStorage.setItem("adminToken", token);

统一请求函数:

js 复制代码
async function api(path, options = {}) {
  const response = await fetch(path, {
    method: options.method || "GET",
    headers: {
      "content-type": "application/json",
      ...(state.token ? { authorization: `Bearer ${state.token}` } : {}),
    },
    body: options.body ? JSON.stringify(options.body) : undefined,
  });

  if (!response.ok) throw new Error(await response.text());
  return response.json();
}

保存资料时:

js 复制代码
await api("/api/admin/profile", {
  method: "PUT",
  body: {
    name: profileName,
    role: profileRole,
    stack: profileStack,
    location: profileLocation,
    status: profileStatus,
    intro: profileIntro,
  },
});

保存文章时:

js 复制代码
await api("/api/admin/posts", {
  method: state.selectedPost ? "PUT" : "POST",
  body: post,
});

十三、前台动态渲染

首页不再写死内容,而是请求:

js 复制代码
const response = await fetch("/api/site");

然后渲染:

js 复制代码
function renderSite(data) {
  document.body.dataset.template = data.template || "terminal";
  setHtml("profile", renderProfile(data.profile));
  setHtml("project-list", renderProjects(data.projects));
  setHtml("link-list", renderLinks(data.links));
  setHtml("post-list", renderPosts(data.posts));
}

这里最关键的是:

js 复制代码
document.body.dataset.template = data.template || "terminal";

它决定当前网站使用哪套模板。

十四、模板切换功能

目前内置三套模板:

text 复制代码
terminal
minimal
cyber

CSS 通过属性选择器控制:

css 复制代码
body[data-template="minimal"] {
  --bg: #f7f7f4;
  --fg: #151515;
  --muted: #686868;
  --line: #deded8;
  background: var(--bg);
}

赛博风:

css 复制代码
body[data-template="cyber"] {
  --bg: #05070a;
  --fg: #eaf7ff;
  --muted: #7b8da1;
  --accent: #00e0ff;
  --line: #143344;
}

后台保存模板时调用:

text 复制代码
PUT /api/admin/template

后端限制模板只能是:

js 复制代码
const TEMPLATES = new Set(["terminal", "minimal", "cyber"]);

这样可以避免前端传入任意值。

十五、文章详情页

文章详情页是:

text 复制代码
post.html

访问方式:

text 复制代码
/post.html?slug=hello-world

页面读取 URL 参数:

js 复制代码
const slug = new URLSearchParams(location.search).get("slug");

然后请求:

js 复制代码
const response = await fetch(`/api/posts/${encodeURIComponent(slug)}`);

拿到文章后渲染标题、日期和正文。

正文使用简单 Markdown 渲染器,支持:

  • 标题
  • 段落
  • 链接
  • 行内代码
  • 代码块

十六、HTML 转义与安全处理

因为后台输入的内容最终会显示在前台,所以渲染时必须做 HTML 转义。

例如:

js 复制代码
export function escapeHtml(value) {
  return String(value ?? "").replace(/[&<>"']/g, (char) => ({
    "&": "&amp;",
    "<": "&lt;",
    ">": "&gt;",
    '"': "&quot;",
    "'": "&#39;",
  })[char]);
}

这个函数用于:

  • 文章标题
  • 项目描述
  • 个人简介
  • 联系方式文字
  • Markdown 普通文本

这样可以避免用户输入破坏页面结构,也能降低 XSS 风险。

十七、部署新版博客

所有功能写完后,执行测试:

bash 复制代码
npm test

测试通过后部署:

bash 复制代码
npx wrangler pages deploy . --project-name geek-blog

部署成功后 Wrangler 会输出预览地址,例如:

text 复制代码
https://xxxxxx.geek-blog-aw8.pages.dev

稳定生产地址:

text 复制代码
https://geek-blog-aw8.pages.dev/

后台地址:

text 复制代码
https://geek-blog-aw8.pages.dev/admin/

十八、线上验证

部署后可以验证几个关键入口:

bash 复制代码
curl https://geek-blog-aw8.pages.dev/api/site

验证文章详情接口:

bash 复制代码
curl https://geek-blog-aw8.pages.dev/api/posts/hello-world

验证后台页面:

text 复制代码
https://geek-blog-aw8.pages.dev/admin/

本项目测试结果:

text 复制代码
# tests 12
# pass 12
# fail 0

说明公开 API、后台 API、渲染函数都通过了基础测试。

十九、部署过程中遇到的问题

1. Wrangler 需要登录

第一次使用 Wrangler 时需要登录:

bash 复制代码
npx wrangler login

浏览器会打开 Cloudflare 授权页面,授权完成后即可部署 Pages 和操作 D1。

2. D1 schema 文件执行失败

有时执行:

bash 复制代码
npx wrangler d1 execute geek_blog_db --remote --file schema.sql

可能因为网络问题失败。

可以先测试:

bash 复制代码
npx wrangler d1 execute geek_blog_db --remote --command "SELECT 1"

如果 SELECT 1 成功,说明 D1 可用,只是 SQL 文件上传流程失败。

这时可以把 schema 拆成多条 --command 执行。

3. 不要把 ADMIN_TOKEN 写进文章

后台 token 只应该保存在 Cloudflare Pages Secret 中。

公开文章中应该写成:

text 复制代码
your-strong-admin-token

不要泄露真实 token。

二十、这个方案适合什么场景

适合:

  • 个人技术博客
  • 个人主页
  • 作品集
  • 小型知识库
  • 不想维护服务器的轻量 CMS
  • 想用 Cloudflare 免费资源搭建动态站点

不太适合:

  • 多人协作后台
  • 复杂权限系统
  • 大规模内容平台
  • 需要全文搜索和复杂工作流的 CMS

总结

这次改造把一个纯静态博客升级成了带数据库和后台管理的在线 CMS。

整体链路是:

text 复制代码
Cloudflare Pages
  ↓
静态前台页面
  ↓
Pages Functions API
  ↓
Cloudflare D1 数据库
  ↓
/admin 后台管理内容

部署流程可以总结为:

bash 复制代码
npx wrangler pages project create geek-blog --production-branch main
npx wrangler d1 create geek_blog_db
npx wrangler d1 execute geek_blog_db --remote --file schema.sql
npx wrangler pages secret put ADMIN_TOKEN --project-name geek-blog
npx wrangler pages deploy . --project-name geek-blog

这个方案最大的优点是轻量。

它不像 WordPress 那样需要维护一整套服务,也不像纯静态博客那样每次改内容都要手动改文件。Cloudflare Pages Functions + D1 刚好提供了一个中间形态:既保留静态站点的速度,又拥有在线后台管理的便利。

对于个人开发者来说,这是一个很值得尝试的建站方案。