用 Next.js 搭建个人博客并部署到 Cloudflare

ShuaiQi
#建站#Next.js#Cloudflare

先确定技术路线

个人博客大致有两种做法:一种是 WordPress 这类动态程序,需要 PHP 运行环境和数据库;另一种是预先生成静态 HTML,再交给 Nginx、Apache 或对象存储托管。这个站点选择的是后者:Next.js 负责页面、路由和构建,文章是 content/ 目录下的 Markdown 文件,next build 时一次性生成全部页面。

静态方案的好处很直接:线上不需要数据库和 Node 进程,攻击面小,HTML 可以被 Cloudflare 缓存,流量上来时也不容易被打垮。代价是发布流程多一步构建,动态功能(例如评论、搜索)需要借助第三方服务或单独的接口。

动手前准备三样东西:本地安装 Node.js(Next.js 16 对版本有要求,具体以官方文档为准)、一个已备案或可解析的域名、一台能运行 Apache 的服务器。Cloudflare 账号免费注册,把域名接进去即可。

初始化项目

用官方脚手架创建项目:

bash
npx create-next-app@latest my-blog
cd my-blog
npm install gray-matter

交互选项里建议开启 TypeScript、App Router 和 Tailwind CSS。初始化完成后执行 npm run dev,浏览器打开 http://localhost:3000,能看到默认页面就说明环境正常。

为了后续维护方便,我习惯把文章解析逻辑单独放在 lib/posts.ts,目录结构大致如下:

text
my-blog/
├─ app/
│  ├─ blog/
│  │  ├─ page.tsx
│  │  └─ [slug]/page.tsx
│  └─ layout.tsx
├─ content/
│  └─ hello-world.md
├─ lib/
│  └─ posts.ts
└─ next.config.ts

其中 content/ 放 Markdown,app/blog/page.tsx 是文章列表,app/blog/[slug]/page.tsx 是文章详情,两者都在构建时读取本地文件。

用 Markdown 管理文章

每篇文章一个 .md 文件,顶部用 YAML frontmatter 记录标题、日期、标签等信息。frontmatter 与正文由 gray-matter 负责拆分,这个库只做解析,不涉及渲染。

一份可用的 frontmatter 长这样:

yaml
---
title: "第一篇文章"
slug: hello-world
date: "2026-11-14"
tags: ["建站", "Next.js", "Cloudflare"]
description: "一句话说明这篇文章讲什么,方便列表页和搜索结果展示。"
---

读取与排序的逻辑可以写得很短:

ts
import fs from "node:fs";
import path from "node:path";
import matter from "gray-matter";

const CONTENT_DIR = path.join(process.cwd(), "content");

export type Post = {
  slug: string;
  title: string;
  date: string;
  tags: string[];
  description: string;
  content: string;
};

export function getAllPosts(): Post[] {
  return fs
    .readdirSync(CONTENT_DIR)
    .filter((file) => file.endsWith(".md"))
    .map((file) => {
      const raw = fs.readFileSync(path.join(CONTENT_DIR, file), "utf8");
      const { data, content } = matter(raw);

      return {
        slug: String(data.slug || file.replace(/\.md$/, "")),
        title: String(data.title || ""),
        date: String(data.date ?? ""),
        tags: Array.isArray(data.tags) ? data.tags.map(String) : [],
        description: String(data.description || ""),
        content,
      };
    })
    .sort((a, b) => (a.date < b.date ? 1 : -1));
}

几个实际踩过的点:

  • date 一定要加引号。YAML 会把不带引号的日期解析成 Date 对象,String() 之后变成很长的带时区字符串,排序和展示都会出问题。
  • slug 要保证唯一,否则静态导出时后一篇文章会覆盖前一篇的目录。可以在读取时用 Set 去重并打印警告。
  • slug 可以用中文,但 URL 里会出现百分号编码。站内链接统一写成 /blog/中文slug/,浏览器和 Next.js 会自动处理编码。
  • content/ 目录必须提交到仓库,并且构建机要能读到;如果使用 CI,别把 Markdown 放在被忽略的目录里。

配置静态导出

Next.js 的静态导出只需在 next.config.ts 里加一个字段:

ts
import type { NextConfig } from "next";

const nextConfig: NextConfig = {
  output: "export",
  trailingSlash: true,
  images: {
    unoptimized: true,
  },
  poweredByHeader: false,
};

export default nextConfig;

output: 'export' 做了什么

开启后,next build 会把每个路由预渲染成 HTML 文件并输出到 out/ 目录,不再生成需要 Node 运行的产物。也就是说 next start 在这种模式下没有意义,部署的只是纯静态文件。

代价是部分能力不可用:服务端组件里不能调用 cookies()、headers() 这类请求时 API,不能使用 ISR,Route Handler 需要显式声明为静态,图片优化服务也不可用,所以要把 images.unoptimized 设为 true。Next.js 16 在这块的细节可能调整过,升级前先看对应版本的官方文档。

trailingSlash 为什么建议开启

默认情况下,Next.js 生成的是 /blog/hello 这样的链接,导出后对应 out/blog/hello.html。而 Apache 更习惯目录加索引文件的模型,访问 /blog/hello/ 时会去找 out/blog/hello/index.html。

开启 trailingSlash: true 后,导出结构变成 out/blog/hello/index.html,与 Apache 的 DirectoryIndex 行为一致,/blog/hello/ 可以直接命中。同时要保证站内链接也带尾斜杠,否则 Cloudflare 和源站可能各做一次跳转,形成多余的重定向甚至循环。

构建与上传

执行构建:

bash
npm run build

产物在 out/ 目录。可以先本地检查:

bash
npx serve out

确认页面、图片、CSS 都正常后再上传。Linux 服务器上我一般用 rsync:

bash
rsync -avz --delete out/ user@your-server:/var/www/blog/

--delete 会删掉源站多余文件,适合整站覆盖;第一次操作前先用 --dry-run 看一眼,避免误删。Windows 下用 WinSCP 或 FileZilla 直接拖 out/ 里的内容也可以。

Apache 站点配置保持简单即可:

apache
<VirtualHost *:80>
    ServerName blog.example.com
    DocumentRoot /var/www/blog

    <Directory /var/www/blog>
        Options -Indexes +FollowSymLinks
        AllowOverride None
        Require all granted
    </Directory>

    ErrorDocument 404 /404.html
</VirtualHost>

静态导出默认会生成 404.html,把它交给 ErrorDocument 就能得到统一的错误页。

Cloudflare DNS 与 SSL

域名接入 Cloudflare 后,把 NS 记录改成 Cloudflare 分配的两个地址,等待生效。然后添加一条 A 记录指向服务器公网 IP,代理状态建议开启(橙色云朵),这样源站 IP 不会直接暴露,也能用到缓存、WAF 和限速。

SSL 模式选 Full (strict),并在源站安装 Cloudflare Origin Certificate 或有效的 Let's Encrypt 证书。不要用 Flexible,否则 Cloudflare 到源站走 HTTP,源站再跳 HTTPS 时容易出现重定向循环。

页面规则里可以顺手打开 Always Use HTTPS 和 Automatic HTTPS Rewrites。缓存方面,HTML 建议不缓存或设置很短的 TTL,/_next/static/ 下的文件名带内容哈希,可以长期缓存。每次发布后如果页面还是旧的,先在 Cloudflare 控制台清理缓存再排查。

源站防火墙最好只放行 Cloudflare 的 IP 段,具体网段会更新,以官方文档公布的列表为准。

常见 404 与资源路径问题

页面 404,但文件确实存在

先确认 out/blog/xxx/index.html 是否存在,再检查链接有没有尾斜杠、大小写是否一致。Linux 文件系统区分大小写,本地 Windows 能访问的 /Blog/xxx/ 到线上就会 404。若部署在子目录,还需要在 next.config.ts 设置 basePath 和 assetPrefix。

CSS、JS 全部 404

多半是资源前缀不对。用浏览器开发者工具看请求的完整路径,如果是 /assets/... 这种与 out/ 结构对不上的地址,检查 basePath、反向代理的路径重写以及 CDN 的缓存规则。

本地正常,线上白屏

先看控制台报错。常见原因是 CDN 缓存了旧 HTML,而旧 HTML 引用的哈希文件已经被新构建覆盖,结果是 404。解决办法是发布时先上传静态资源,最后替换 HTML,或者用软链接做原子切换,再清理缓存。

部署后部分文章打不开

检查是否存在重复 slug,或者文件名里的特殊字符在 URL 编码后与期望不一致。中文 slug 建议保持全站统一,不要混用空格和百分号。

内容维护

日常写作流程就是新建 Markdown、填好 frontmatter、本地预览、提交仓库、构建上传。文章多了以后,可以按标签生成归档页,也可以把构建交给 CI,推送到主分支后自动发布。如果不想手写 Markdown,可以给站点接入图形化后台,具体做法见 如何给自己的网站接入CMS。

常见问题

静态导出后还能使用 API 路由吗?

不能使用依赖请求上下文的动态接口。Route Handler 只能在构建时执行并输出静态结果,登录、提交表单这类功能需要外部服务或独立的服务端。

为什么必须设置 images.unoptimized?

静态导出没有 Next.js 图片优化服务器,next/image 默认会请求优化接口,线上就会 404。设为 true 后直接输出原始图片地址,需要压缩和转格式的话可以交给 Cloudflare 或构建脚本处理。

trailingSlash 开启后旧链接会失效吗?

旧的带斜杠链接能正常工作,不带斜杠的链接可能多一次 301 跳转。若已经有外部收录,建议在 Cloudflare 或 Apache 上配置统一跳转规则,避免出现重复 URL。

每次更新都要清理 Cloudflare 缓存吗?

只更新文章时通常不需要,因为 HTML 没有被长时间缓存。如果改了 _next/static 下的资源或调整了缓存规则,发布后清理一次整站缓存最稳妥。

Cloudflare 代理开启后源站还能直接访问吗?

可以,但建议用防火墙只允许 Cloudflare 网段访问 80 和 443,避免别人绕过 CDN 直连源站。需要本地调试时再临时放行自己的 IP。

评论