ヘッドレスCMSのフィールド設計|クライアントが更新しても崩れない管理画面にする
納品後の「更新したら崩れた」は操作ミスより設計が原因。本文リッチテキスト1本をやめて意味ごとにフィールドを割り、繰り返しは器で持ち、0件や長文はテンプレート側で吸収する。自サイトEmDashの実装から具体的に。

「更新したら実績ページが崩れた」。ヘッドレスCMSを納品して数週間後、制作会社経由でこの連絡が来ることがあります。開いてみると、クライアントは何も乱暴なことをしていない。本文欄に見出しをひとつ足しただけ、写真を1枚差し替えただけ。壊れる余地のある器を渡した、こちらの設計の問題です。
自分のポートフォリオもEmDashで動かしていて、実績ページのフィールドは一度組み直しました。そのとき効いた判断を、実際の定義とテンプレートのコードごと置いておきます。
崩れるCMSは、たいてい「本文リッチテキスト1本」でできている
リッチテキスト(EmDashなら portableText)は文章のための器です。ここに仕様を持った構造を入れると、見出しの階層も並び順も画像の置き方も、全部が編集者の手に渡ってしまう。
自サイトの実績(projects)では、以前は本文に混ぜて書いていた項目を独立したフィールドに切り出しました。
project_scope(課題と目的)/deliverables(担当範囲・納品物)/tech_stack(使用技術)/implemented_features(実装機能)/timeline_weeks(制作期間)/project_url(公開URL)- 詳細ページはこのカスタムフィールドが主表示。本文(content)は補足に降格させた
並び順を決めるのはテンプレートなので、編集者が何をどう書いても順番は崩れません。型がついているぶん、一覧カードやOGP、構造化データへの再利用も効く。リッチテキストを消す話ではなく、文章はリッチテキスト、仕様は専用フィールドと役割を分けるだけです。

型は「いちばん狭いもの」を選ぶ
EmDashのフィールド型は16種類あります(string / text / integer / boolean / datetime / select / multiSelect / portableText / image / file / reference / json / repeater ほか)。設計のコツは、入るものが決まっているなら一番狭い型まで降りること。
- 制作期間は string ではなく
integer。「3週間」「3w」「約1ヶ月」といった表記ゆれが物理的に起きません - 選択肢が決まっているなら
select/multiSelect。許可値はvalidation.optionsに持たせる - 別のエントリと紐づけるなら
reference(自由入力のIDコピペをやめられる) - 短い語の集合は
json+ Field Kit の tags ウィジェット
{ "slug": "timeline_weeks", "label": "制作期間(週)", "type": "integer" },
{
"slug": "tech_stack",
"label": "使用技術",
"type": "json",
"widget": "field-kit:tags",
"options": {
"placeholder": "技術を入力して Enter",
"allowCustom": true,
"suggestions": ["HTML5", "CSS3", "TypeScript", "WordPress", "Astro"]
}
}「あとで何を入れるか分からないから、とりあえず文字列で」が事故の入り口。候補を出しておけば、入力する側も迷いません。
繰り返しは「器」で持たせる
同じ形のものが何個も並ぶ箇所——実績のリスト、経歴、スキル、FAQ。ここをどう持たせるかで管理画面の使い勝手が決まります。選択肢はだいたい4つ。
- 改行区切りの text:いちばん軽い。ラベルに「1行に1つ」と書いておく
- repeater:1件が複数の項目を持つとき
- Field Kit の list:並べ替えが要るとき。行の見出しテンプレート(summary)や件数の上限(min / max)まで指定できる
- Field Kit の tags:短い語の集合。候補を出して自由入力を絞れば表記ゆれが減る

自サイトの経歴は repeater です。1件が「年月・出来事・説明・継続中フラグ」の4つを持つので、改行区切りでは足りませんでした。
{
"slug": "timeline",
"label": "経歴",
"type": "repeater",
"validation": {
"subFields": [
{ "slug": "year", "type": "string", "label": "年月" },
{ "slug": "label", "type": "string", "label": "出来事" },
{ "slug": "desc", "type": "text", "label": "説明" },
{ "slug": "current", "type": "boolean", "label": "現在も継続中" }
]
}
}list / tags / object-form / grid はプラグイン(Field Kit)側の実装です。器の引き出しとして持っておくと設計が速くなる。詳しくはEmDashでACF風のカスタムフィールドを作る話に書きました。
0件・長文・大量は、テンプレート側で吸収する
フィールドを細かく割ると、今度は「全部埋まっている前提」のテンプレートになりがちです。実際にクライアントが埋めるのは半分くらい。自サイトの実績詳細では、2つの仕掛けで吸収しています。
// text(改行区切り) / repeater[{value}] / 旧string[] のいずれも文字列配列に正規化
function toStrings(v: unknown): string[] | undefined {
let arr: unknown[];
if (typeof v === "string") arr = v.split("\n");
else if (Array.isArray(v)) arr = v;
else return undefined;
const out = arr
.map((x) => (x && typeof x === "object" && "value" in x ? String(x.value) : String(x)))
.map((s) => s.trim())
.filter((s) => s.length > 0);
return out.length > 0 ? out : undefined;
}
const hasCustomFields =
project.data.project_scope ||
(deliverables && deliverables.length > 0) ||
(techStack && techStack.length > 0) ||
project.data.timeline_weeks != null ||
project.data.project_url;toStrings は、入力の形が途中で変わっても表示側が壊れないようにするための正規化。空白だけの行を捨て、結果が0件なら undefined を返します。hasCustomFields のほうは、全部空ならセクションごと出さないための判定。これで「担当範囲」という見出しだけが残る、あの気まずい事故が消えます。
長すぎる入力も同じ考え方で、器の幅を先に決めておく(自サイトは本文カラムの最大幅をテーマ変数で固定しています)。CMSに入る文字数は、必ず想定より長くなります。可変テキストの崩れ方はFigmaで作ったのにCMSで文字が溢れる話にまとめました。
ラベルとヘルプ文が、実際にいちばん読まれる仕様書
- ラベルに入力ルールを埋める。自サイトは「担当範囲・納品物(1行に1つ)」「制作期間(週)」。操作マニュアルは読まれないのに、入力欄の真上にある文字は読まれます
- required は「無いとページが壊れるもの」だけ。実績で必須にしているのは title と featured_image の2つ。必須を増やすと途中保存すらできなくなり、結局メモ帳に書き溜められて終わる
- 下書きとプレビューを付けて渡す。EmDashなら collection の
supportsにdrafts/previewを入れる。公開前に本番と同じ見た目で確認できると、テストがてら公開してしまう事故が減ります(ヘッドレスCMSのプレビュー実装)

フィールド設計の良し悪しは、納品して数か月後の問い合わせ件数にそのまま出ます。器を絞るほどクライアントは迷わず、こちらの電話も鳴らない。管理画面は、渡したあとの手離れまで含めて設計するものだと思っています。
ヘッドレスCMSの構築や既存サイトのCMS入れ替えで、フィールド設計から相談したい制作会社の方はお問い合わせからどうぞ。既存スキーマの見直しだけでも承ります。
本記事は2026年7月時点の情報です。