タグを文字列で持つと、あとから一覧ページが作れない——ヘッドレスCMSの分類設計
記事に付く「タグ」を、ただの文字列フィールドで持つか、CMSの分類機能(タクソノミー)で持つか。この選択が、一覧ページ・絞り込み・関連記事の実装を後から縛ります。自サイトの実績とブログで使い分けている実装を開きながら、着手前に決めたい4点を整理しました。

「ブログにカテゴリ機能を足したいんですが、あとからでもできますか」。納品して半年経ったサイトで、こう聞かれたことがある。答えは「タグをどう持ったかによる」でした。記事の下に並んでいる"タグらしきもの"が、CMSの分類機能なのか、ただの文字が入った項目なのか。見た目は同じ小さなピルでも、この違いが半年後に効いてくる。
分類の設計は、フィールド設計と同じで作り直しが重い部分です。着手前に決めておきたい4つを、自分のサイトの実装を開きながら整理します。
文字列フィールドは表示が簡単、タクソノミーは記事を絞り込める
分かれ道はここです。
- 文字列フィールド:記事のデータの中に
["Astro", "SEO"]という文字が入っているだけ。表示は簡単で、追加も自由。ただし「このタグが付いた記事」をデータベースに聞けない。 - タクソノミー(分類機能):用語(term)が記事とは別の実体として登録され、記事と用語が関連づけられる。EmDashなら標準でカテゴリ(階層あり)とタグ(階層なし)の2つが用意されていて、用語ごとにslug・表示名・説明・親を持てます。

自分のサイトは両方を使い分けています。実績(projects)はタクソノミー。ブログ記事はjson型のtagsフィールド。だからブログのタグはピルとして並ぶだけで、リンクになっていません。飛び先の一覧ページが無いからです。
代償は関連記事の実装に出ました。ブログの記事ページでは、最新100件を読み込んでから、タグの一致数をJavaScriptで数えて並べ替えています。
// タグが文字列だと、絞り込みはアプリ側の総当たりになる
const { entries: allPosts } = await getEmDashCollection("blog_posts", {
orderBy: { post_date: "desc" },
limit: 100,
});
const relatedPosts = allPosts
.map((p) => ({ p, score: (p.data.tags ?? []).filter((t) => currentTags.has(t)).length }))
.sort((a, b) => b.score - a.score);記事が100本を超えたら、この窓の外にある関連記事は拾えません。実績側は同じことを1回のクエリで済ませています。
判断の目安はこうです。一覧ページ・絞り込み・件数表示のどれかを将来やる可能性があるなら、タクソノミー。記事の下に飾りとして出すだけならフィールドで足ります。迷ったらタクソノミーにしておく。文字列からの移行は手作業です。用語を作り直して、全記事に貼り直すことになるので。
絞り込みはデータベースに投げる
タクソノミーにしたうえで、実装でよくつまずくのが絞り込みの書き方です。全件読んでからJavaScriptでfilterすると、記事数×用語数に比例して重くなる。数十件のうちは動いてしまうので、気づくのが納品後になりがちなんです。
実績一覧では、絞り込み条件をそのままクエリに渡しています。
const activeTag = Astro.url.searchParams.get("tag");
const [{ entries, cacheHint }, tags] = await Promise.all([
getEmDashCollection("projects", {
orderBy: { published_at: "desc" },
...(activeTag ? { where: { tag: activeTag } } : {}),
}),
getTaxonomyTerms("tag"),
]);
// カードのタグは1回のJOINでまとめて取る(カードごとに呼ぶとN+1になる)
const tagsByEntry = await getTermsForEntries("projects", entries.map((p) => p.data.id), "tag");もうひとつ、用語一覧を取るgetTaxonomyTermsは既定で各用語の記事数を数えます。この集計がこの呼び出しの中でいちばん重い処理なので、表示名とslugしか使わない画面では{ includeCounts: false }を渡して省けます。

URLをパスにするかクエリにするか、canonicalを先に決める
/tag/astroのようにパスで持つか、/work?tag=astroのようにクエリで持つか。検索から直接入ってほしい分類はパス、UIの絞り込みでしかないならクエリ、と分けています。
どちらにしても、同じ記事が複数のURLから見える状態になる。canonical(正規URL)の方針を先に決めておかないと、重複コンテンツの温床になります。自サイトの実装は「クエリは既定で全部捨て、内容を実際に変えるものだけ明示的に残す」です。
<Base canonicalParams={["tag"]}>utm_sourceやfbclidが付いただけで別ページ扱いになるのを、これで防いでいます。パスで持つ場合は増える分だけページが生まれるので、3記事しかないタグページを量産しないよう、一覧を作る件数のしきい値も先に決めておくと後が楽です。

増えすぎるタグを止める決めごと
運用で必ず起きるのが、タグの増殖と表記ゆれ。「Astro」「astro」「アストロ」が並ぶあれです。仕組みで止めるなら、決めることは3つ。
- 誰が用語を新規作成できるか。EmDashでは用語の追加・編集に
taxonomies:manage権限が要るので、権限設計と一緒に決めます(クライアントに管理画面をどこまで渡すか) - 分類の軸を混ぜない。カテゴリは階層、タグは並列。軸が2つ以上あるなら、独自のタクソノミーを追加して分ける
- 消したときの挙動を共有しておく。用語を削除しても記事は消えず、割り当てだけ外れます。子を持つ用語は、子を先に消さないと削除できません
WordPressからの移行なら、カテゴリとタグは階層を保ったまま運べます(WordPressからEmDashへ移行する手順)。裏を返せば、いま散らかっている分類はそのまま散らかったまま入る。移行は分類を整理する数少ない機会です。フィールド側の設計とセットで考えると、管理画面が崩れにくくなります(ヘッドレスCMSのフィールド設計)。
Astro・ヘッドレスCMSでの分類設計や、既存サイトの一覧・絞り込みの実装でお困りでしたら、お問い合わせからご相談ください。
本記事は2026年9月時点の情報です。