从静态博客到带后台的在线 CMS:使用 Cloudflare Pages Functions + D1 部署个人博客
之前我做了一个纯静态个人 Geek 博客最初只有 HTML、CSS、JavaScript 和一些文章静态文件。部署到 Cloudflare Pages 之后访问速度很快也不用维护服务器。但是纯静态博客有一个明显缺点每次修改文章、个人简介、联系方式、项目链接都需要手动改文件然后重新部署。于是我把它升级成了一个带数据库、带后台管理页面的博客系统前台Cloudflare Pages后端 APICloudflare Pages Functions数据库Cloudflare D1后台页面/admin/管理内容个人资料、联系方式、项目、文章、模板鉴权方式Cloudflare Pages Secret 中保存ADMIN_TOKEN最终效果是不需要自己买服务器也不需要部署传统 Node.js 后端就能拥有一个可以在线编辑内容的个人博客 CMS。C:\Users\86182\Desktop\我的博客可以上瘾了一、最终项目结构改造后的项目结构如下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 APIschema.sql是 D1 数据库初始化脚本wrangler.toml是 Cloudflare 项目配置assets/render.js封装前台渲染逻辑二、整体架构整个系统的架构非常轻量浏览器 │ ├─ 访问 / 博客首页 ├─ 访问 /post.html 文章详情页 └─ 访问 /admin/ 后台管理页 Cloudflare Pages │ ├─ 托管 HTML/CSS/JS 静态资源 └─ Pages Functions 提供 /api/* 接口 Cloudflare D1 │ └─ 保存文章、项目、联系方式、个人资料、模板配置这个架构的好处是不需要服务器不需要数据库运维不需要 Nginx不需要 PM2不需要传统后端部署前端、API、数据库都在 Cloudflare 上完成三、创建 Cloudflare Pages 项目首先使用 Wrangler 创建 Cloudflare Pages 项目npx wrangler pages project create geek-blog --production-branch main创建成功后Cloudflare 会分配一个默认域名例如https://geek-blog-aw8.pages.dev/后面所有部署都会发布到这个 Pages 项目。四、创建 D1 数据库带后台的博客必须有地方存数据这里使用 Cloudflare D1。执行npx wrangler d1 create geek_blog_db创建成功后Wrangler 会返回一个database_id类似database_id xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx记住这个 ID后面要写入wrangler.toml。五、配置 wrangler.toml项目根目录创建wrangler.tomlname geek-blog pages_build_output_dir . compatibility_date 2026-06-29 [[d1_databases]] binding DB database_name geek_blog_db database_id 你的-database-id这里最重要的是binding DB因为后端函数中会通过env.DB访问 D1 数据库。例如awaitenv.DB.prepare(SELECT key, value FROM settings).all();六、设计数据库表数据库初始化脚本是schema.sql。这套博客系统设计了四张表。1. settings 表CREATETABLEIFNOTEXISTSsettings(keyTEXTPRIMARYKEY,valueTEXTNOTNULL);settings用来保存全局配置例如当前模板个人资料因为这些数据结构比较灵活所以直接用 JSON 字符串保存。2. links 表CREATETABLEIFNOTEXISTSlinks(idINTEGERPRIMARYKEYAUTOINCREMENT,labelTEXTNOTNULL,urlTEXTNOTNULL,sort_orderINTEGERNOTNULLDEFAULT0);用于保存联系方式例如GitHubEmailCSDNX/Twitter3. projects 表CREATETABLEIFNOTEXISTSprojects(idINTEGERPRIMARYKEYAUTOINCREMENT,nameTEXTNOTNULL,descriptionTEXTNOTNULL,urlTEXTNOTNULL,tagsTEXTNOTNULLDEFAULT[],sort_orderINTEGERNOTNULLDEFAULT0);用于保存项目展示信息。其中tags使用 JSON 数组字符串保存例如[web,typescript]4. posts 表CREATETABLEIFNOTEXISTSposts(idINTEGERPRIMARYKEYAUTOINCREMENT,slugTEXTNOTNULLUNIQUE,titleTEXTNOTNULL,excerptTEXTNOTNULLDEFAULT,contentTEXTNOTNULL,statusTEXTNOTNULLDEFAULTpublished,published_atTEXTNOTNULL,updated_atTEXTNOTNULLDEFAULTCURRENT_TIMESTAMP);文章表中比较关键的是slug字段。文章访问方式是/post.html?slughello-world而不是直接暴露数据库 id。七、初始化数据库创建好schema.sql后执行npx wrangler d1 execute geek_blog_db--remote--fileschema.sql如果网络环境导致--file上传失败也可以用多条--command分别执行 SQL。例如npx wrangler d1 execute geek_blog_db--remote--commandSELECT 1如果返回成功说明 D1 远程连接正常。八、实现公开 API前台需要两个公开接口GET /api/site GET /api/posts/:slug1. 首页聚合接口文件functions/api/site.js核心代码exportasyncfunctiononRequestGet({env}){constsettingsRowsawaitenv.DB.prepare(SELECT key, value FROM settings).all();constsettingsObject.fromEntries(settingsRows.results.map((row)[row.key,parseJson(row.value,row.value),]));constlinksawaitenv.DB.prepare(SELECT label, url FROM links ORDER BY sort_order, id).all();constprojectsawaitenv.DB.prepare(SELECT name, description, url, tags FROM projects ORDER BY sort_order, id).all();constpostsawaitenv.DB.prepare(SELECT slug, title, excerpt, published_at FROM posts WHERE status published ORDER BY published_at DESC, id DESC).all();returnjson({template:settings.template||terminal,profile:settings.profile||{},links:links.results,projects:projects.results.map(normalizeProject),posts:posts.results,});}这个接口一次性返回首页需要的所有数据当前模板个人资料联系方式项目列表文章列表这样前台只需要请求一次/api/site。2. 文章详情接口文件functions/api/posts/[slug].js代码exportasyncfunctiononRequestGet({env,params}){constpostawaitenv.DB.prepare(SELECT slug, title, excerpt, content, published_at FROM posts WHERE slug ? AND status published).bind(params.slug).first();if(!post){returnjson({error:Post not found},404);}returnjson(post);}这里使用了 D1 参数绑定.bind(params.slug)比字符串拼接 SQL 更安全。九、实现后台鉴权后台不能直接公开写入接口所以需要简单鉴权。这里采用ADMIN_TOKEN方式。后台登录时输入 token前端保存到sessionStorage之后请求后台接口时带上Authorization: Bearer token鉴权代码放在functions/api/admin/_auth.js核心代码exportfunctionisAuthorized(request,env){constheaderrequest.headers.get(authorization)||;consttokenheader.replace(/^Bearer\s/i,);returnBoolean(env.ADMIN_TOKENtokentokenenv.ADMIN_TOKEN);}exportfunctionrequireAdmin(request,env){if(!isAuthorized(request,env)){returnjson({error:Unauthorized},401);}returnnull;}登录接口POST /api/admin/login后台写操作都会调用requireAdmin()。十、实现后台管理 API后台 API 包括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以文章管理为例exportasyncfunctiononRequestPost({request,env}){constauthrequireAdmin(request,env);if(auth)returnauth;constpostcleanPost(awaitreadJson(request));if(!validatePost(post))returnjson({error:Invalid post},400);awaitenv.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();returnjson({post});}更新文章exportasyncfunctiononRequestPut({request,env}){constauthrequireAdmin(request,env);if(auth)returnauth;constpostcleanPost(awaitreadJson(request));if(!validatePost(post))returnjson({error:Invalid post},400);awaitenv.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();returnjson({post});}删除文章exportasyncfunctiononRequestDelete({request,env}){constauthrequireAdmin(request,env);if(auth)returnauth;constbodyawaitreadJson(request);constslugString(body?.slug||).trim();if(!slug)returnjson({error:Missing slug},400);awaitenv.DB.prepare(DELETE FROM posts WHERE slug ?).bind(slug).run();returnjson({ok:true});}十一、设置后台管理密钥后台 token 不应该写死在代码里而应该作为 Cloudflare Pages Secret 保存。执行npx wrangler pages secret put ADMIN_TOKEN --project-name geek-blog然后输入一个强密码例如your-strong-admin-token注意不要把真实 token 写进公开博客或 GitHub 仓库。十二、实现后台页面后台入口/admin/主要文件admin/index.html admin/admin.css admin/admin.js后台页面分为五个模块资料联系方式项目文章模板登录成功后把 token 保存到sessionStoragesessionStorage.setItem(adminToken,token);统一请求函数asyncfunctionapi(path,options{}){constresponseawaitfetch(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)thrownewError(awaitresponse.text());returnresponse.json();}保存资料时awaitapi(/api/admin/profile,{method:PUT,body:{name:profileName,role:profileRole,stack:profileStack,location:profileLocation,status:profileStatus,intro:profileIntro,},});保存文章时awaitapi(/api/admin/posts,{method:state.selectedPost?PUT:POST,body:post,});十三、前台动态渲染首页不再写死内容而是请求constresponseawaitfetch(/api/site);然后渲染functionrenderSite(data){document.body.dataset.templatedata.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));}这里最关键的是document.body.dataset.templatedata.template||terminal;它决定当前网站使用哪套模板。十四、模板切换功能目前内置三套模板terminal minimal cyberCSS 通过属性选择器控制body[data-templateminimal]{--bg:#f7f7f4;--fg:#151515;--muted:#686868;--line:#deded8;background:var(--bg);}赛博风body[data-templatecyber]{--bg:#05070a;--fg:#eaf7ff;--muted:#7b8da1;--accent:#00e0ff;--line:#143344;}后台保存模板时调用PUT /api/admin/template后端限制模板只能是constTEMPLATESnewSet([terminal,minimal,cyber]);这样可以避免前端传入任意值。十五、文章详情页文章详情页是post.html访问方式/post.html?slughello-world页面读取 URL 参数constslugnewURLSearchParams(location.search).get(slug);然后请求constresponseawaitfetch(/api/posts/${encodeURIComponent(slug)});拿到文章后渲染标题、日期和正文。正文使用简单 Markdown 渲染器支持标题段落链接行内代码代码块十六、HTML 转义与安全处理因为后台输入的内容最终会显示在前台所以渲染时必须做 HTML 转义。例如exportfunctionescapeHtml(value){returnString(value??).replace(/[]/g,(char)({:amp;,:lt;,:gt;,:quot;,:#39;,})[char]);}这个函数用于文章标题项目描述个人简介联系方式文字Markdown 普通文本这样可以避免用户输入破坏页面结构也能降低 XSS 风险。十七、部署新版博客所有功能写完后执行测试npmtest测试通过后部署npx wrangler pages deploy.--project-name geek-blog部署成功后 Wrangler 会输出预览地址例如https://xxxxxx.geek-blog-aw8.pages.dev稳定生产地址https://geek-blog-aw8.pages.dev/后台地址https://geek-blog-aw8.pages.dev/admin/十八、线上验证部署后可以验证几个关键入口curlhttps://geek-blog-aw8.pages.dev/api/site验证文章详情接口curlhttps://geek-blog-aw8.pages.dev/api/posts/hello-world验证后台页面https://geek-blog-aw8.pages.dev/admin/本项目测试结果# tests 12 # pass 12 # fail 0说明公开 API、后台 API、渲染函数都通过了基础测试。十九、部署过程中遇到的问题1. Wrangler 需要登录第一次使用 Wrangler 时需要登录npx wrangler login浏览器会打开 Cloudflare 授权页面授权完成后即可部署 Pages 和操作 D1。2. D1 schema 文件执行失败有时执行npx wrangler d1 execute geek_blog_db--remote--fileschema.sql可能因为网络问题失败。可以先测试npx wrangler d1 execute geek_blog_db--remote--commandSELECT 1如果SELECT 1成功说明 D1 可用只是 SQL 文件上传流程失败。这时可以把 schema 拆成多条--command执行。3. 不要把 ADMIN_TOKEN 写进文章后台 token 只应该保存在 Cloudflare Pages Secret 中。公开文章中应该写成your-strong-admin-token不要泄露真实 token。二十、这个方案适合什么场景适合个人技术博客个人主页作品集小型知识库不想维护服务器的轻量 CMS想用 Cloudflare 免费资源搭建动态站点不太适合多人协作后台复杂权限系统大规模内容平台需要全文搜索和复杂工作流的 CMS总结这次改造把一个纯静态博客升级成了带数据库和后台管理的在线 CMS。整体链路是Cloudflare Pages ↓ 静态前台页面 ↓ Pages Functions API ↓ Cloudflare D1 数据库 ↓ /admin 后台管理内容部署流程可以总结为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--fileschema.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 刚好提供了一个中间形态既保留静态站点的速度又拥有在线后台管理的便利。对于个人开发者来说这是一个很值得尝试的建站方案。