你正在讀的這個部落格沒有資料庫,也沒有託管 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 很難被超越。

