コンテンツのローカル編集用 CMS に Keystatic を試用
このブログでは、コンテンツ管理にデータベースを使わず、Markdown フォーマットで書かれた記事を Git および GitHub で管理する方法を採用しています。 コンテンツ編集は、(エンジニアでもあるので).md ファイルを直接エディタで編集する方法でも良いのですが、ブログのメタデータを frontmatter で良い感じに管理できそうなので、Front Matter CMS を導入しました。
Front Matter CMS は、名前の通り、コンテンツのメタデータ部分 frontmatter の YAML を UI 管理することに全振りしていて、ブログコンテンツの編集については、ある意味 VS Code にお任せ、という設計思想です。 そういう意味では、まだ本格的なコンテンツ編集機能を備えた CMS は導入しておりませんので、先の記事の CMS 候補リストでも候補上位に挙げていた、Keystatic を試用してみることにしました。
Keystatic とは
Keystatic は、Keystone CMS なども手がけているオーストラリア・シドニーのソフトウェア企業 Thinkmill が作った Git-based CMS です。 この会社はハイレベルのソフトウェア設計・開発コンサルティングを本業としているテック企業で、母国オーストラリアでは超メジャーな存在ですし、この Keystatic もエンジニア好みの「Git ベースで管理できる Headless CMS」として非常に高い評価のようです。
候補リストに挙げていた同じ Git-based CMS 系の Decap CMS と比べると、スキーマ記述が型安全・UI が現代的・開発元が活発、といった強みがあります。
- Git ベースのコンテンツ管理:
Markdown, JSON, YAMLなどのコンテンツを Git リポジトリに保存 - ファイルシステム / GitHub API の両対応:
開発中はファイルをローカル編集、デプロイ後は GitHub API 経由でリポジトリに直接コミット - ブラウザベースの管理 UI:
/keystaticにアクセスするとダッシュボード、記事一覧、フィールドごとの GUI エディタが使える - 軽量シンプルなコンテンツ編集:
WYSIWYG よりは Markdown 記法の意味(構造)に寄り添ったコンテンツ・エディタ
Markdown は、歴史的な経緯から様々なフォーマットの方言があり、なかなか統一されていない(むしろ派閥が乱立して戦国時代)という問題があります。
この Keystatic の場合、スキーマ選択で Markdoc か MDX かの選択はできますが、シンプルな GFM/CommonMark が選択肢にないようなのが気にはなりますが、まずは使ってみることにしました。
導入
npm install @keystatic/core @keystatic/next
このブログは Next.js の App Router 前提です(Pages Router でも動きますが、ルート設定が異なります)。App Router 構成では以下のファイルを用意します:
keystatic.config.ts: コレクション定義とストレージ設定src/app/keystatic/keystatic.tsx:makePage(config)で管理画面 SPA を作るコンポーネント本体src/app/keystatic/layout.tsx: 上の SPA を render するだけsrc/app/keystatic/[[...params]]/page.tsx: 空(return null)— Next.js のルーティングマッチ用の placeholdersrc/app/api/keystatic/[...params]/route.ts: API ルート
layout.tsx に SPA を置いて page.tsx を空にする変則パターンは Keystatic 特有で、SPA が URL 遷移で再マウントされないようにするためです。各ファイルの具体的な記述は Keystatic 公式の Next.js インストールガイド を参照してください。
この記事では、私が設定で実際にハマった項目に絞って説明します。
ローカルで動かす
.mdoc がデフォルト問題
Keystatic は Markdoc 形式が標準で、ファイル拡張子は .mdoc がデフォルト。既存の .md ファイルを認識させるには、明示的に拡張子を指定する必要があります。
content: fields.markdoc({
label: "Content",
extension: "md", // ← これ
// ...
}),
この設定がないとコレクションの記事一覧が空っぽになり、気づかないと「Keystatic 側に記事が表示されない、なぜ…」となります。
Next.js の Route Groups で UI を分離
Keystatic は Next.js のルート(/keystatic)として組み込まれます。素直に追加すると、サイトの root layout(ヘッダーやサイドバー付き)に包まれてしまい、CMS の管理画面の周辺にブログのヘッダーや右ペインのタグ一覧などが表示された雑多な状態になります。
コンテンツ編集するのにこれらは邪魔なだけで不要ですので、消してしまいたいところ。
これは Next.js の Route Groups の変更で解決しました。(site) という括弧付きディレクトリを作ると、URL には影響せず layout だけ分離できます。
src/app/
├── layout.tsx ← root layout(html/body・共通フォント・計測のみ)
├── (site)/
│ ├── layout.tsx ← サイト用 layout(ヘッダー、サイドバー、フッター)
│ ├── [lang]/
│ ├── credits/
│ └── page.tsx
└── keystatic/ ← root layout だけ適用される
├── keystatic.tsx ← 管理画面 SPA 本体(makePage(config))
├── layout.tsx ← SPA を render するだけ
└── [[...params]]/
└── page.tsx ← 空の placeholder
(site) は URL に出ないので既存 URL は変わらず、Keystatic 側には root layout だけが適用されてブログのサイドバーなどは表示しない、という綺麗な分離ができました。
slugField は frontmatter から消える
Keystatic の slugField に指定したフィールドは、schema に定義していても 保存時に frontmatter から自動的に削除される 仕様です。削除された値は失われるわけではなく、ファイル名(= パスの一部)として保持されます。
Keystatic の世界観では「slug はファイル名であり、frontmatter に持つものではない」(二重管理になる)という設計思想です。
これについての詳しい背景と設計判断は、先に試用していた Front Matter CMS の記事 slug をどう管理するか にも書きました。 SSG / Git-based CMS 界隈では「ファイル名 = slug」がデフォルトの流儀で、Keystatic もその流れに乗って、忠実に実装しているんですね。
このブログも同じ思想(slug は frontmatter に書かない)を採用しましたので、Keystatic の stripping 挙動は問題になりません。
keystatic.config.ts では slug 型のフィールドを schema に残しつつ、slugField: "slug" でファイル名として扱います:
const postSchema = {
title: fields.text({ /* ... */ }),
slug: fields.text({
label: "Slug",
description: "Derived from file path. This value is ignored by posts.ts.",
validation: { length: { min: 10 } },
}),
// ... 他のフィールド
};
postsJa: collection({
path: "posts/ja/**",
slugField: "slug",
// ...
});
schema に slug フィールドを残すのは、Keystatic の UI で slug(= ファイルパス部分)を表示・編集できるようにするため。
Keystatic 上から新規記事を作成する際には、Slug 欄に例えば 2026/04/my-first-post など入力すると、保存時はそれに .md が付加されてファイル名になり、frontmatter にはフィールドとしては残さない — という挙動になります。
空のオプション・フィールドは省略される
description や cover_image などの必須ではないフィールドを空のまま Keystatic で保存すると frontmatter から省略されます。
値を入れれば書き戻されるので致命傷ではないですが、Front Matter CMS は description: "" のように空文字列でもそのままキーを残す流儀で、両者の違いとして押さえておくべきポイントです。
## 見出しが
に化ける問題(options を全部盛る)
Keystatic の content フィールドのネイティブ形式は Markdoc(Stripe が作った構造化ドキュメント言語)です。Markdown のスーパーセット的な立ち位置ですが、設計思想がかなり違います。
| Markdown (CommonMark) | Markdoc | |
|---|---|---|
| 思想 | テキストっぽく書けば何でもパースする寛容さ | スキーマで許可したノードのみ受け付ける厳格さ |
| 未知の HTML | そのまま通す | 文字列扱い |
| 用途 | 自由な執筆 | 構造を保証したい CMS / ドキュメントサイト |
Keystatic は CMS なので Markdoc 流儀(=スキーマで明示的に許可)を採用しており、fields.markdoc の options で許可した要素だけを「構造化された見出し/画像/リンク」として扱います。それ以外は生 HTML として保持されます。
つまり options をほぼ空のまま使うと、## 見出し のような Markdown 基本構文も「許可されていない」扱いになり、Keystatic で開いて保存した瞬間に <h2>見出し</h2> という生 HTML に化けます。しかも保存するとその HTML がそのままファイルに書き戻されます。
これに対処するため、Markdoc がサポートする CommonMark + GFM 相当の構文を keystatic.config.ts 内の設定で全部有効化しました。
content: fields.markdoc({
label: "Content",
extension: "md",
options: {
// インライン装飾
bold: true,
italic: true,
strikethrough: true,
code: true,
link: true,
// ブロック要素
heading: [1, 2, 3, 4, 5, 6],
blockquote: true,
orderedList: true,
unorderedList: true,
table: true,
divider: true,
codeBlock: true,
// 画像(設定あり)
image: {
directory: "public/images/posts",
publicPath: "/images/posts/",
},
},
}),
これで ## 見出しもリストもコードブロックもテーブルも、Keystatic 側でも正しく構造として認識されるようになりました。
ちなみに「Markdown 全部許可」をワンストップで行う設定はありませんでした。地道に全部羅列するしかないようです。
ただし、options を全部盛りにしても、GFM(GitHub Flavored Markdown)でお馴染みの パイプ記号 | で書くテーブル(パイプテーブル) については問題が残りました。
table: true を設定すると、パイプテーブルを含む Markdown は Keystatic 上で正しく読み込まれ、UI 上もテーブルとして表示されます。
ところが保存時には、Markdoc 独自の {% table %} 記法に変換されて書き戻されてしまいます。{% table %} は構造化された記法ですが、ソースを眺めてテーブル構造が読み取れるパイプテーブルとは見た目がかなり異なり、Markdoc レンダラーを持たない環境(GitHub の md プレビューや多くのエディタ等)ではテーブルとしてレンダリングされません。
これはまさに冒頭で気にしていた「Markdown 方言問題」の具体例で、この挙動自体は Keystatic の設定では変更できません(シリアライズ形式を切り替えるオプションが存在しない)。回避策も含めた掘り下げは別記事として Markdoc テーブル問題 にまとめます。
新規記事のパスは slug にスラッシュを含める
このブログは posts/ja/2026/04/hello-world.md のように年月でサブディレクトリを切っています。config の path: "posts/ja/**" で ** ワイルドカード(glob 記法、任意の深さのサブディレクトリにマッチ)が効くため、既存ファイルは正しく認識されます。
問題は新規作成です。Slug 欄に test とだけ入れると、posts/ja/test.md と、設定パスのディレクトリ直下にフラットに置かれてしまい、年月ディレクトリに入りません。
Front Matter CMS なら {{year}}/{{month}} テンプレートで自動振り分けできますが、Keystatic にそのような機能はありませんでした。
これの解決法は、単純に Slug 欄にスラッシュ入りの値を入れる だけでした。path の ** がそれをサブディレクトリとして解釈してくれます(ちゃんと 公式ドキュメント にも書いてありました)。
File Path: 2026/04/my-new-post
→ posts/ja/2026/04/my-new-post.md
File Path: 2026/05/another-post
→ posts/ja/2026/05/another-post.md (ディレクトリも自動作成)
年月部分は毎回手入力する必要がありますが、それで適正なパスに生成ファイルが配置されれば、実用上は十分です。
まとめ(ローカル編)
Keystatic 単体のローカルセットアップは、Route Groups の分離と options 全部盛り、slugField の罠への対処が主な作業で、ここまでは比較的素直でした。
冒頭で気にしていた Markdown 方言の違いは、実際に使ってみると ## 見出しが <h2> に化ける件や、GFM パイプテーブルが {% table %} に変換される件として、具体的な問題として現れました。
元々この試用は Front Matter CMS と併用する前提で進めていました。
しかし、両 CMS で同じ Markdown ファイルを編集対象として共有すると、Markdoc 方言の違い以外にも、Keystatic のドラフト仕様や SPA キャッシュが外部編集を握りつぶすといった、それぞれの設計思想の違いに由来する様々なハマりどころがでてきました。
そのあたりの顛末は別記事 Front Matter CMS と Keystatic を共用すると設計思想の違いが見えてきた にまとめました。
Keystatic 単体をローカルで動かすところまでは、これで一通り完了です。
Vercel Preview にデプロイして「モバイルや外出先からも書ける」ようにする話は、環境変数の切り分け、GitHub App のセットアップ、middleware でのアクセス制御など、また別に色々と作業が必要でした。これも別記事 Keystatic を Vercel Preview にデプロイする にまとめていきます。