「ブログにカテゴリ機能を足したいんですが、あとからでもできますか」。納品して半年経ったサイトで、こう聞かれたことがある。答えは「タグをどう持ったかによる」でした。記事の下に並んでいる"タグらしきもの"が、CMSの分類機能なのか、ただの文字が入った項目なのか。見た目は同じ小さなピルでも、この違いが半年後に効いてくる。

分類の設計は、フィールド設計と同じで作り直しが重い部分です。着手前に決めておきたい4つを、自分のサイトの実装を開きながら整理します。

文字列フィールドは表示が簡単、タクソノミーは記事を絞り込める

分かれ道はここです。

  • 文字列フィールド:記事のデータの中に ["Astro", "SEO"] という文字が入っているだけ。表示は簡単で、追加も自由。ただし「このタグが付いた記事」をデータベースに聞けない。
  • タクソノミー(分類機能):用語(term)が記事とは別の実体として登録され、記事と用語が関連づけられる。EmDashなら標準でカテゴリ(階層あり)とタグ(階層なし)の2つが用意されていて、用語ごとにslug・表示名・説明・親を持てます。
文字列フィールドとタクソノミーの比較表。持ち方、一覧ページ、絞り込み、件数の表示、向く用途の5項目を並べている

自分のサイトは両方を使い分けています。実績(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 }を渡して省けます。

絞り込みを実装するときの3点を並べたカード図。DBに投げる、まとめて引く、件数は任意

URLをパスにするかクエリにするか、canonicalを先に決める

/tag/astroのようにパスで持つか、/work?tag=astroのようにクエリで持つか。検索から直接入ってほしい分類はパス、UIの絞り込みでしかないならクエリ、と分けています。

どちらにしても、同じ記事が複数のURLから見える状態になる。canonical(正規URL)の方針を先に決めておかないと、重複コンテンツの温床になります。自サイトの実装は「クエリは既定で全部捨て、内容を実際に変えるものだけ明示的に残す」です。

<Base canonicalParams={["tag"]}>

utm_sourceやfbclidが付いただけで別ページ扱いになるのを、これで防いでいます。パスで持つ場合は増える分だけページが生まれるので、3記事しかないタグページを量産しないよう、一覧を作る件数のしきい値も先に決めておくと後が楽です。

タグのURLをパスで持つ場合とクエリで持つ場合の比較表。性格、検索の入口、canonical、注意の4項目

増えすぎるタグを止める決めごと

運用で必ず起きるのが、タグの増殖と表記ゆれ。「Astro」「astro」「アストロ」が並ぶあれです。仕組みで止めるなら、決めることは3つ。

  • 誰が用語を新規作成できるか。EmDashでは用語の追加・編集にtaxonomies:manage権限が要るので、権限設計と一緒に決めます(クライアントに管理画面をどこまで渡すか)
  • 分類の軸を混ぜない。カテゴリは階層、タグは並列。軸が2つ以上あるなら、独自のタクソノミーを追加して分ける
  • 消したときの挙動を共有しておく。用語を削除しても記事は消えず、割り当てだけ外れます。子を持つ用語は、子を先に消さないと削除できません

WordPressからの移行なら、カテゴリとタグは階層を保ったまま運べます(WordPressからEmDashへ移行する手順)。裏を返せば、いま散らかっている分類はそのまま散らかったまま入る。移行は分類を整理する数少ない機会です。フィールド側の設計とセットで考えると、管理画面が崩れにくくなります(ヘッドレスCMSのフィールド設計)。

Astro・ヘッドレスCMSでの分類設計や、既存サイトの一覧・絞り込みの実装でお困りでしたら、お問い合わせからご相談ください。

本記事は2026年9月時点の情報です。