返回博客

不用 CMS 和数据库,在 Next.js 中搭建 Markdown 博客

不用 CMS 和数据库,在 Next.js 中搭建 Markdown 博客

2026年10月8日 — Next.js

发布于

2026年10月8日

阅读时长

3 分钟

分类

Next.js

标签

  • Next.js
  • Markdown
  • Blog
  • remark
  • Static Site

(摘要)每篇文章都是 Git 仓库里的一个 Markdown 文件。用 Next.js App Router 实现 front matter、代码高亮、图片、视频和静态页面。

你正在读的这个博客没有数据库,也没有托管 CMS。每篇文章都是 Git 仓库里的一个文件夹,里面有一个 index.md 和它的图片。推送一次提交,网站就会构建并发布文章。下面介绍如何用 Next.js App Router 搭建同样的博客。

为什么把 Markdown 放在 Git 里

  • 免费又快。 不用 CMS 订阅,没有数据库查询,每个页面都是静态 HTML。
  • 有版本记录。 每次修改都是一个可以审阅、可以回滚的提交。
  • 可迁移。 Markdown 比任何平台活得都久。换框架只需要换渲染器,不用迁移内容。
  • 随处可编辑。 GitHub 网页编辑器、VS Code,或者给偏好表单的人用基于 Git 的 CMS,比如 Decap 或 Sveltia。

代价是:不懂技术的编辑者需要会一点 Git,或者在前面加一个基于 Git 的 CMS。

文件夹结构

每篇文章一个文件夹,图片和引用它的文字放在一起——这样相对路径在 GitHub 上预览也是正确的:

text
content/blog/
  my-first-post/
    index.md
    cover.jpg
    diagram.png

文章以 YAML front matter 开头:

content/blog/my-first-post/index.md
---
title: My first post
date: 2026-10-08
excerpt: One or two sentences for the listing and the meta description.
cover: ./cover.jpg
tags: [Next.js, Markdown]
---

The post starts here.

依赖

bash
npm install gray-matter remark remark-gfm remark-html highlight.js
  • gray-matter 把 front matter 和正文分开。
  • remark + remark-gfm 解析 Markdown,支持 GitHub 扩展语法(表格、任务列表)。
  • remark-html 把它转成 HTML。
  • highlight.js 在构建时高亮代码块,读者不需要下载高亮库。

读取文章

lib/blog.ts
import fs from 'node:fs'
import path from 'node:path'
import matter from 'gray-matter'
import { remark } from 'remark'
import remarkGfm from 'remark-gfm'
import remarkHtml from 'remark-html'

const DIR = path.join(process.cwd(), 'content/blog')

export async function getAllPosts() {
  const slugs = fs.readdirSync(DIR).filter((name) =>
    fs.existsSync(path.join(DIR, name, 'index.md')),
  )
  const posts = await Promise.all(slugs.map(getPost))
  return posts.sort((a, b) => b.date.localeCompare(a.date))
}

export async function getPost(slug: string) {
  const file = fs.readFileSync(path.join(DIR, slug, 'index.md'), 'utf8')
  const { data, content } = matter(file)
  const html = String(
    await remark().use(remarkGfm).use(remarkHtml, { sanitize: false }).process(content),
  )
  return {
    slug,
    title: String(data.title),
    date: new Date(data.date).toISOString().slice(0, 10),
    excerpt: String(data.excerpt ?? ''),
    html,
  }
}

sanitize: false 允许文章中的原始 HTML 通过——如果只有你一个作者,这没问题。如果其他人也能提交文章,请保留过滤。

因为这些代码只在服务端组件和 generateStaticParams 中运行,使用 fs 没有问题:它在构建时运行,永远不会出现在浏览器里。

页面

列表页列出文章;详情路由为每个 slug 生成静态页面:

app/blog/[slug]/page.tsx
import { getAllPosts, getPost } from '@/lib/blog'

export const dynamicParams = false

export async function generateStaticParams() {
  return (await getAllPosts()).map(({ slug }) => ({ slug }))
}

export default async function PostPage({ params }: { params: Promise<{ slug: string }> }) {
  const { slug } = await params
  const post = await getPost(slug)
  return (
    <article>
      <h1>{post.title}</h1>
      <div dangerouslySetInnerHTML={{ __html: post.html }} />
    </article>
  )
}

构建时的代码高亮

一个小小的 remark 插件,把每个代码节点替换成高亮后的 HTML:

lib/highlight.ts
import hljs from 'highlight.js'
import { visit } from 'unist-util-visit'

export function highlightCode() {
  return (tree: any) => {
    visit(tree, 'code', (node: any, index, parent: any) => {
      const lang = node.lang && hljs.getLanguage(node.lang) ? node.lang : null
      const html = lang
        ? hljs.highlight(node.value, { language: lang }).value
        : hljs.highlightAuto(node.value).value
      parent.children[index!] = {
        type: 'html',
        value: `<pre><code class="hljs language-${lang ?? 'text'}">${html}</code></pre>`,
      }
    })
  }
}

在 remarkHtml 之前用 .use(highlightCode) 加上它,再引入一个 highlight.js 主题,比如 highlight.js/styles/github-dark.css。

图片和视频

Next.js 从 public/ 提供静态文件,所以文章的资源需要放到那里。写一个小脚本,在 dev 和 build 之前把所有非 Markdown 文件复制过去:

scripts/copy-blog-media.mjs
import fs from 'node:fs'
import path from 'node:path'

const SRC = 'content/blog'
const OUT = 'public/blog-media'
fs.rmSync(OUT, { recursive: true, force: true })

for (const slug of fs.readdirSync(SRC)) {
  const dir = path.join(SRC, slug)
  if (!fs.statSync(dir).isDirectory()) continue
  for (const file of fs.readdirSync(dir)) {
    if (file.endsWith('.md')) continue
    fs.mkdirSync(path.join(OUT, slug), { recursive: true })
    fs.copyFileSync(path.join(dir, file), path.join(OUT, slug, file))
  }
}
package.json
{
  "scripts": {
    "predev": "node scripts/copy-blog-media.mjs",
    "prebuild": "node scripts/copy-blog-media.mjs"
  }
}

然后在另一个 remark 插件里,把相对图片路径(./cover.jpg)改写成 /blog-media/<slug>/cover.jpg。顺便把指向 .mp4 或 .webm 的图片变成 <video controls>,把 YouTube 链接变成嵌入播放器——作者继续用普通的 Markdown 图片语法就行。

把 public/blog-media 加入 .gitignore,它是生成出来的。

发布

把仓库连接到静态托管平台——Cloudflare Pages、Netlify 或 Vercel——构建命令设为 npm run build。现在写一篇文章就是:

  1. 新建一个文件夹和 index.md。
  2. 提交并推送。
  3. 托管平台在几分钟内完成构建和部署。

值得加上的 SEO 细节

  • 用 generateMetadata 设置文章的标题、摘要、canonical 链接,并把封面作为 Open Graph 图片。
  • 一个列出所有文章的 sitemap.ts。
  • BlogPosting 类型的 JSON-LD。
  • 标题 ID,方便链接到具体章节。

这些都在 Next.js App Router 技术 SEO 清单里有介绍。

什么时候该换方案

如果有好几位不懂技术的人每天发布内容,需要定时发布或编辑审核流程,无头 CMS 就开始物有所值了。对于个人网站、工作室或每月几篇文章的产品博客,Git 里的 Markdown 很难被超越。

订阅

通过邮件接收新文章

有新文章时发一封简短的邮件。不发垃圾邮件,随时可以退订。

用 Cloudflare Workers 和 D1 搭建无服务器联系表单
用 Cloudflare Workers 和 D1 搭建无服务器联系表单
下一篇 →