このサイトの記事は、Markdownファイルとして Git で管理しています。一方で、公開と非公開は管理画面のチェックボックスで切り替えられます。
CMSは導入していません。本文はGit、公開状態はD1と役割を分け、公開状態が変わったらサイトを再ビルドする構成です。この記事では、その設計と実装のポイントを紹介します。
実現したかったこと
- 記事本文と画像は、コードと同じリポジトリで管理する
- 記事を書き終えても、公開は別のタイミングで判断する
- 公開ごとにコミットしない
- 複数の記事をまとめて公開する
- 公開中の記事が0件なら、LPに記事へのリンクを出さない
- 配信は静的HTMLのままにする
公開の反映に数分かかっても問題がなかったので、リクエストのたびにデータベースを読むのではなく、ビルド時に公開状態を取り込む方式を選びました。
全体の構成
処理の流れは次のとおりです。
- 管理画面で記事を選び、公開または非公開を選ぶ
- Pages Functions が D1 の公開状態を更新する
- Deploy Hook を呼び出し、再ビルドを開始する
- ビルド時に公開APIを呼び、公開中の記事の一覧を取得する
- 公開中の記事だけをHTMLとして出力する

未公開の記事はビルド結果に含まれないので、URLを直接開いても404になります。
D1には公開状態だけを持つ
テーブルには、公開中の記事のスラッグと公開日だけを保存します。
CREATE TABLE article_publications (
slug TEXT PRIMARY KEY,
published_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ', 'now'))
);
行があれば公開中、なければ未公開です。真偽値の列を持たないので、状態の組み合わせを考える必要がありません。タイトルや本文はGit側にあり、D1に重複して保存しないようにしています。

まとめて公開しても再デプロイは1回
公開APIは、スラッグの配列を受け取ります。更新は batch でまとめて実行し、その後で Deploy Hook を1回だけ呼びます。
const statement = env.DB.prepare(
body.published
? "INSERT INTO article_publications (slug) VALUES (?1) ON CONFLICT (slug) DO NOTHING"
: "DELETE FROM article_publications WHERE slug = ?1",
);
await env.DB.batch(body.slugs.map((slug) => statement.bind(slug)));
return json({ ok: true, deployed: await triggerDeploy(env) });
ON CONFLICT DO NOTHING にしているので、公開中の記事を含めて再度公開しても、最初の公開日は変わりません。
Deploy Hookの失敗に備える
Deploy Hook は外部へのHTTPリクエストなので、失敗する場合があります。ただし、この時点で公開状態の保存は完了しています。
そこで、再デプロイの失敗をエラーとして扱うのではなく、結果を画面に返すようにしました。
export async function triggerDeploy(env: Env): Promise<boolean> {
if (!env.PAGES_DEPLOY_HOOK_URL) {
return false;
}
try {
const response = await fetch(env.PAGES_DEPLOY_HOOK_URL, { method: "POST" });
return response.ok;
} catch (error) {
console.error("deploy hook failed", (error as Error).message);
return false;
}
}
失敗したときは、管理画面の再反映ボタンから再実行できます。Deploy Hook のURLを知っていればビルドを起動できるので、URLはシークレットとして登録し、リポジトリには含めません。
取得に失敗したらビルドを止める
ビルド時に公開APIを取得できなかった場合の扱いは重要です。一時的な障害で公開記事が0件と判定されると、すべての記事がサイトから消えてしまいます。
そのため、取得に失敗したらビルドを失敗させます。
if (!response.ok) {
throw new Error(`公開記事を取得できません: HTTP ${response.status}`);
}
Cloudflare Pages では、ビルドが失敗しても公開中のデプロイはそのまま残ります。記事が消えるより、更新が遅れるほうが安全だと判断しました。
例外は404です。APIを初めてデプロイするときは、公開中のサイトにAPIがまだ存在しないので、このときだけ記事なしでビルドを続けます。

ローカルでは未公開の記事も確認する
未公開の記事を確認するため、環境変数で全件を出力できるようにしました。
if (process.env.ARTICLES_PREVIEW === "all") {
return sources.map(({ slug, updatedAt }) => ({ slug, published_at: updatedAt }));
}
また、記事の frontmatter は、未公開の記事も含めてビルド時に検証します。公開するタイミングで初めて不備に気づくことを防ぐためです。
この方式が向いている場合
| 向いている場合 | 向いていない場合 |
|---|---|
| 書き手がGitを扱える | 非エンジニアが本文を編集する |
| 反映に数分かかっても問題がない | 公開を即時に反映する必要がある |
| 記事の本数や更新頻度が多くない | 予約公開や編集の承認が多数必要になる |
逆に、本文の編集者が複数いる場合や、公開の予約が必要な場合は、ヘッドレスCMSの導入も検討する価値があります。
まとめ
本文をGit、公開状態をD1に分けることで、静的サイトの配信方式を保ったまま、管理画面から記事を公開できるようになりました。全体の仕組みが小さくなり、記事が0件のときの表示も、ビルド結果だけで制御できます。

