你正在读的这个博客没有数据库,也没有托管 CMS。每篇文章都是 Git 仓库里的一个文件夹,里面有一个 index.md 和它的图片。推送一次提交,网站就会构建并发布文章。下面介绍如何用 Next.js App Router 搭建同样的博客。
为什么把 Markdown 放在 Git 里
- 免费又快。 不用 CMS 订阅,没有数据库查询,每个页面都是静态 HTML。
- 有版本记录。 每次修改都是一个可以审阅、可以回滚的提交。
- 可迁移。 Markdown 比任何平台活得都久。换框架只需要换渲染器,不用迁移内容。
- 随处可编辑。 GitHub 网页编辑器、VS Code,或者给偏好表单的人用基于 Git 的 CMS,比如 Decap 或 Sveltia。
代价是:不懂技术的编辑者需要会一点 Git,或者在前面加一个基于 Git 的 CMS。
文件夹结构
每篇文章一个文件夹,图片和引用它的文字放在一起——这样相对路径在 GitHub 上预览也是正确的:
content/blog/
my-first-post/
index.md
cover.jpg
diagram.png文章以 YAML front matter 开头:
---
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.依赖
npm install gray-matter remark remark-gfm remark-html highlight.jsgray-matter把 front matter 和正文分开。remark+remark-gfm解析 Markdown,支持 GitHub 扩展语法(表格、任务列表)。remark-html把它转成 HTML。highlight.js在构建时高亮代码块,读者不需要下载高亮库。
读取文章
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 生成静态页面:
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:
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 文件复制过去:
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))
}
}{
"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。现在写一篇文章就是:
- 新建一个文件夹和
index.md。 - 提交并推送。
- 托管平台在几分钟内完成构建和部署。
值得加上的 SEO 细节
- 用
generateMetadata设置文章的标题、摘要、canonical 链接,并把封面作为 Open Graph 图片。 - 一个列出所有文章的
sitemap.ts。 BlogPosting类型的 JSON-LD。- 标题 ID,方便链接到具体章节。
这些都在 Next.js App Router 技术 SEO 清单里有介绍。
什么时候该换方案
如果有好几位不懂技术的人每天发布内容,需要定时发布或编辑审核流程,无头 CMS 就开始物有所值了。对于个人网站、工作室或每月几篇文章的产品博客,Git 里的 Markdown 很难被超越。

