はじめに
はじめまして、株式会社メンバーズ メンバーズルーツカンパニーの荒井です。
普段はCMS移行案件を中心に、Jamstack領域の業務をおこなっています。
microCMSには「コンテンツ参照」という機能が存在します。このコンテンツ参照を活用することで運用するメディアサイトに「カテゴリごとのタグ一覧」や「タグごとの記事一覧」、「著者ごとの記事一覧」を導入することができます。
この記事では「コンテンツ参照」の実用例として、「カテゴリごとの記事一覧」を実装したので、その方法を紹介します。
今回はメディアサイトと相性の良いAstroを用いて作成しましたので、ぜひ参考にしてみてください。
※本記事では Astro 5.17.1 を使用しています
実装の背景
普段の業務でメディアサイトに触れていると、「カテゴリごとの記事一覧ページ」や「著者ごとの記事一覧ページ」が必ずと言っていいほど設置されていることに気づきました。
弊社が運営しているメディアサイトに目を向けてみると、BEMA Labやメンバーズルーツカンパニー公式サイトにも「カテゴリごとの記事一覧ページ」が存在します。
さらに、外に目を向けてみればQiitaやZennといった技術系メディアサイトにも同様の機能が見受けられます。
これまでの例からわかる通り、メディアサイトと「カテゴリや執筆者ごとの記事一覧ページ」は切っても切り離せない関係にあります。
microCMSではCMS側で「コンテンツ参照」を活用してカテゴリ > タグ > 記事のような階層構造を作ることが可能です。
ただし、APIの返り値には「そのコンテンツを参照している記事一覧」のデータ(逆参照のデータ)は含まれない仕様となっています。 そのため、カテゴリのAPIを受け取っただけでは「カテゴリごとの記事一覧ページ」は作成できず、フロントエンド側でデータの紐付けを行う必要があります。
そこで、microCMSを採用した環境でも「カテゴリやタグ、執筆者ごとの記事一覧ページ」を実装する方法を記事にすることにしました。
また、今回はメディアサイトの規模が大きくなっていくことを想定し、フラットなカテゴリ管理ではなく、親カテゴリ - 子カテゴリの階層構造を持ったカテゴリでの実装を目指します。
作成する機能の概要
今回は「カテゴリやタグ、執筆者ごとの記事一覧ページ」を「カテゴリの階層構造」に着目して実装していきます。
「親カテゴリを複数参照する子カテゴリ」と「子カテゴリを複数参照する記事コンテンツ」を用意することで、メディアサイトによくある「複数のカテゴリにまたがるタグ」と「複数のタグを持った記事」を再現しよう、という形です。
- 今アクセスしている「カテゴリ」に割り当てられている
idを取得する - 記事一覧から「参照元に指定した
idを持っている記事」を抽出する - 抽出した記事一覧を表示する
データモデル解説
microCMSの管理画面側で作成するコンテンツのデータモデルについても解説します。
下記に示す画像の通りです。
- 親カテゴリAPI(parent_category):テキストフィールドのみを持っています。このテキストフィールドは親カテゴリの名前として機能します。
- 子カテゴリAPI(child_category):テキストフィールドとコンテンツ参照を持っています。テキストフィールドの役割は親カテゴリと同様で、コンテンツ参照では親カテゴリを参照させます。
- 記事API(articles):テキストフィールド、リッチテキストフィールド、コンテンツ参照を持っています。テキストフィールドの役割は前述の二つと同様です。リッチテキストフィールドに記事の本文を格納して、コンテンツ参照では子カテゴリを参照させます。
microCMSのコンテンツリファレンス関係

microCMS 管理画面での設定
astroファイルを作成する前に、microCMS上にコンテンツを作成しましょう。
1. 親カテゴリ
今回は以下に示す画像の通り、テキストフィールドの「カテゴリ名」だけを持ったものとして作成します。
2. 子カテゴリ
子カテゴリは親カテゴリを複数持てるようにしたいため、テキストフィールドの「カテゴリ名」と、親カテゴリを複数参照する「親カテゴリ」を持たせます。
3. 記事コンテンツ
記事コンテンツはテキストフィールドの「記事タイトル」とリッチエディタの「記事内容」を持たせます。
また、子カテゴリを複数参照する「カテゴリ」を持たせることで、「記事を作成するときは子カテゴリだけを選べばいい状況」を実現します。
実装手順
それでは、Astro側での実装説明を行っていきます。
Astroコードの全体像
まず、今回作成するページのディレクトリ構成は下記の通りです。
src/
├── assets/
├── components/
│ └── header.astro
├── content/
│ ├── config.ts
│ └── schema.ts
├── layouts/
│ └── Layout.astro
├── library/
│ └── microcms.ts
└── pages/
├── category/
│ └── [id].astro
└── index.astro
この中の pages/category/[id].astro が「カテゴリやタグ、執筆者ごとの記事一覧ページ」にあたるものです。
以下に pages/category/[id].astro のコード全文を示します。
---
import { getCollection } from "astro:content";
import Layout from "../../layouts/Layout.astro";
const { id } = Astro.params;
const articles = await getCollection("articles");
const filteredArticles = articles.filter((article) => {
const matchChildCategories = article.data.categories?.some((category) => category.id === id);
const matchParentCategories = article.data.categories?.some((childCategory) => childCategory.parentCategories?.some((category) => category.id === id));
return matchChildCategories || matchParentCategories;
});
export async function getStaticPaths() {
const parentCategories = await getCollection("parentCategories");
const childCategories = await getCollection("childCategories");
return [
...parentCategories.map((category) => ({ params: { id: category.id } })),
...childCategories.map((category) => ({ params: { id: category.id } })),
];
}
---
<Layout>
<h1>記事一覧</h1>
<div>
<p>全{filteredArticles.length}件</p>
<ul>
{filteredArticles.map((article) => (
<li>
<a href={`/article/${article.id}`}>{article.data.title}</a>
</li>
))}
</ul>
</div>
</Layout>
このコードのフロントマター(---で囲まれた箇所)にある filteredArticles が「カテゴリやタグ、執筆者ごとの記事一覧ページ」を作るのにあたって最も大切な箇所になっています。
順を追って説明します。
記事コンテンツのAPI返り値
今回作成した記事コンテンツのAPIは次のような値を返してきます。
{
"id": 自動採番される記事自体のID,
"createdAt": 記事が作成された日付と時刻,
"updatedAt": 記事が更新された日付と時刻,
"publishedAt": 記事が初めて公開された日付と時刻,
"revisedAt": 記事が更新、再公開された日付と時刻,
"title": 記事コンテンツのタイトル,
"categories": [
{
"id": 自動採番される子カテゴリのID,
"createdAt": 子カテゴリが作成された日付と時刻,
"updatedAt": 子カテゴリが更新された日付と時刻,
"publishedAt": 子カテゴリが初めて公開された日付と時刻,
"revisedAt": 子カテゴリが更新、再公開された日付と時刻,
"categoryName": 子カテゴリの名前,
"parentCategories": [
{
"id": 子カテゴリが参照している親カテゴリのID
}
]
},
],
"content": 記事のリッチテキストコンテンツ
}
この返り値から、microCMSで記事コンテンツに設定した「子カテゴリ参照」が、「親カテゴリ」の情報も持ってきてくれていることがわかります。
つまり、「カテゴリやタグ、執筆者ごとの記事一覧ページ」を作成する際には、「全ての記事の内、今見ているページの id と同じ id を持っている記事はどれか」を探して表示させる式を書けばいいということになるのです。
このことを踏まえて次のセクションに移ります。
フィルター関数の詳細
前のセクションで、「カテゴリやタグ、執筆者ごとの記事一覧ページ」を作成する際には、「全ての記事の内、今見ているページの id と同じ id を持っている記事はどれか」を探して表示させる式を書けばいいと述べました。
では、それをどうやって書いているのかを解説します。
まず、フィルター関数に関連する記述は次のものです。
const { id } = Astro.params;
const articles = await getCollection("articles");
const filteredArticles = articles.filter((article) => {
const matchChildCategories = article.data.categories?.some((category) => category.id === id);
const matchParentCategories = article.data.categories?.some((childCategory) => childCategory.parentCategories?.some((category) => category.id === id));
return matchChildCategories || matchParentCategories;
});
ひとつひとつ見ていきましょう。
まず、 const { id } = Astro.params; です。
今回の「カテゴリやタグ、執筆者ごとの記事一覧ページ」は pages/category/[id].astro というパスが示す通り、カテゴリごとの id を受け取って生成されたページです。
このとき受け取ってくる id をこの後のフィルターに利用するために、ここで宣言しています。
次に const articles = await getCollection("articles"); 。これは記事の全量をCollectionというキャッシュから取ってくるように指示しています。
では、この取ってきた記事全量をどうやってフィルタリングするの?というのが
const filteredArticles = articles.filter((article) => {
const matchChildCategories = article.data.categories?.some((category) => category.id === id);
const matchParentCategories = article.data.categories?.some((childCategory) => childCategory.parentCategories?.some((category) => category.id === id));
return matchChildCategories || matchParentCategories;
});
という関数です。
この関数では記事全量の内から「今見ているページの id を子カテゴリが持っているか?」あるいは「今見ているページの id を子カテゴリが持っている親カテゴリが持っているか?」を調べ、一致している記事のみを filteredArticles に格納します。(余談ですが、この時に参照する親カテゴリ一覧、子カテゴリ一覧も Collection というキャッシュから取ってくるように指示しています。)
つまり、この filteredArticles を利用することで、当初の目標であった「カテゴリやタグ、執筆者ごとの記事一覧ページ」を実現させることができるのです。
ちょうど良いので、フィルター関数のあとに記述されている getStaticPaths についても軽く触れておきましょう。
export async function getStaticPaths() {
const parentCategories = await getCollection("parentCategories");
const childCategories = await getCollection("childCategories");
return [
...parentCategories.map((category) => ({ params: { id: category.id } })),
...childCategories.map((category) => ({ params: { id: category.id } })),
];
}この getStaticPaths では、親カテゴリと子カテゴリの両方の全データを取得し、それぞれの id をページのパスとして生成するようにAstroに指示しています。これにより、親・子のどちらの id がURLに入っても、正しくこのページコンポーネントが処理されるようになっているのです。
ちょっと応用編
今回、親カテゴリと子カテゴリを並列の関係で処理しましたが、メディアサイトでよく見るのは「/category/親カテゴリ/子カテゴリ」ですよね。
このセクションでは、このURLを実現する動的ルーティングの方法を紹介します。
まずはファイル名を変更しましょう。
今見ているファイルは pages/category/[id].astro となっていますが、これを pages/category/[...id].astro に変更します。
こうすることで、このファイル単体で「/category/親カテゴリ/子カテゴリ」も「/category/親カテゴリ」も表現できるようになります。
では、コードを書き換えていきましょう。
以下に、変更後のコードを示します。
export async function getStaticPaths() {
const parentCategories = await getCollection("parentCategories");
const childCategories = await getCollection("childCategories");
const paths: {
params: { id: string };
props: { type: string; categoryId: string };
}[] = [];
parentCategories.forEach((parent) => {
paths.push({
params: { id: parent.id },
props: {
type: "parent",
categoryId: parent.id
},
});
});
childCategories.forEach((child) => {
if (child.data.parentCategories && child.data.parentCategories.length > 0) {
child.data.parentCategories.forEach((parentRef: any) => {
paths.push({
params: { id: `${parentRef.id}/${child.id}` },
props: {
type: "child",
categoryId: child.id
},
});
});
}
});
return paths;
}関数の頭で親カテゴリ一覧と子カテゴリ一覧を Collection から取得するのは変わりませんが、大事なのはその次です。const paths の内側でこれから設定するパスや、記事のフィルターのために使う情報をどんな形で持たせるのかを設定しています。paramsで設定したのは「/category/親カテゴリ/子カテゴリ」と「/category/親カテゴリ」を実現するためのパス設定、props で設定したのは、この後の記事フィルタリングのために使うパラメータです。
では、この変更で記事フィルタリング周りにどのような変化が起きるのでしょうか?
この動的ルート生成にあわせた記事フィルタリング関数は次のようになります。
const { type, categoryId } = Astro.props;
const articles = await getCollection("articles");
const filteredArticles = articles.filter((article) => {
if (type === "parent") {
return article.data.categories?.some((childCategory) =>
childCategory.parentCategories?.some((parentCategory: any) => parentCategory.id === categoryId)
);
} else if (type === "child") {
return article.data.categories?.some((childCategory) => childCategory.id === categoryId);
}
return false;
});
さて、記事の全件取得は変化しませんが、Astro.props に置かれている内容が変化しました。id から type, categoryId に変化しましたね。これらは先ほどの動的ルート生成関数の内側で作成した paths の中の props を取り出すための準備です。
この理解の上、記事フィルタリング関数をみてみると、type に保存された「親カテゴリなのか、子カテゴリなのか」を参照して、これまで id を用いて行なっていた記事フィルタリングを行なっています。
これまでは「今のパスが親カテゴリに対応するものか子カテゴリに対応するものかは置いておいて、パスから取れる id が親子のうちのどこかに一致する記事の集団を作って返す」という処理をしていたところが「今のパスが親カテゴリに対応するのか子カテゴリに対応するのかを type から判別して、それにあった記事リストを返す。もし、type に親でも子でもないものが返ってきたら false を返す」という処理に変わりました。
フィルター結果の利用方法
では最後に、フィルター結果である filteredArticles を用いて記事の一覧を表示していきましょう。
Astroコードの全体像で示したものの内、表示に関わるのは次の部分です。
<Layout>
<h1>記事一覧</h1>
<div>
<p>全{filteredArticles.length}件</p>
<ul>
{filteredArticles.map((article) => (
<li>
<a href={`/article/${article.id}`}>{article.data.title}</a>
</li>
))}
</ul>
</div>
</Layout>
ここで大事なのは {filteredArticles.map((article) => ()} です。
フィルター関数はフロントマターで定義、実施しただけでは効力を発揮できません。
実際にフィルタリング結果を活用するためには、使いたい部分で呼び出す必要があります。
この部分では filteredArticles の中身を展開して、記事一件一件について <a href={`/article/${article.id}`}>{article.data.title}</a>
を用いることでリンクを作成しています。
実際に動かしてみよう
では、これまで示してきたコードでどんな動作が実現できるのかを示していきます。
microCMSでのコンテンツ作成
今回は次のようにコンテンツを作っていきます。
親カテゴリ

子カテゴリ

「親カテゴリ」の箇所に親カテゴリの名前が表示され、参照が正しく機能していることがわかると思います。
記事コンテンツ

「カテゴリ」の箇所に参照元にした子カテゴリの名前が書かれていることから、参照が正しく機能していることがわかります。
これで、最初に示したデータモデル(下記画像)が実現されていることがわかると思います。
では、これらのコンテンツを受け取ってページに表示してみましょう!
コード側での受け皿作成
※ library/microcms.ts にて、microCMSのSDK初期化設定(APIキーの設定など)は既に完了していることを前提とします。
microCMSからデータを受け取るのには受け皿となるスキーマが必要です。
今回は下記の通りに schema.ts を作成します。
import { z } from 'astro:content';
export const baseSchema = z.object({
id: z.string(),
createdAt: z.coerce.date().optional(),
updatedAt: z.coerce.date().optional(),
publishedAt: z.coerce.date().optional(),
revisedAt: z.coerce.date().optional(),
})
export const parentCategoriesSchema = baseSchema.extend({
categoryName: z.string().optional()
})
export const childCategoriesSchema = baseSchema.extend({
categoryName: z.string(),
parentCategories: z.array(parentCategoriesSchema).optional(),
})
export const articlesSchema = baseSchema.extend({
title: z.string(),
content: z.string(),
categories: z.array(childCategoriesSchema).optional(),
})
そして、受け取ったデータを Collection に保存しましょう。
保存のために、次のような config.ts を書きます
import { defineCollection } from 'astro:content';
import { client } from '../library/microcms';
import { parentCategoriesSchema, childCategoriesSchema, articlesSchema } from './schema'
const parentCategoriesCollection = defineCollection({
loader: async () => {
const categoriesResponse = await client.get({
endpoint:"parent_categories"
});
return categoriesResponse.contents.map((content: any) => ({
id: content.id,
...content
}));
},
schema: parentCategoriesSchema,
});
const childCategoriesCollection = defineCollection({
loader: async () => {
const categoriesResponse = await client.get({
endpoint:"child_categories"
});
return categoriesResponse.contents.map((content: any) => ({
id: content.id,
...content
}));
},
schema: childCategoriesSchema,
});
const articlesCollection = defineCollection({
loader: async () => {
const articlesResponse = await client.get({
endpoint:"articles"
});
return articlesResponse.contents.map((content: any) => ({
id: content.id,
...content
}));
},
schema: articlesSchema,
});
export const collections = {
parentCategories: parentCategoriesCollection,
childCategories: childCategoriesCollection,
articles: articlesCollection,
}
最後のセクションで export const collections をしていますね。これで Collection に受け取ったデータを保存できます。このお陰で、ページの呼び出しのたびにmicroCMSに通信が発生することを防げます。
画面表示の作成
では、今回はナビゲーションバーをヘッダーに作成して、各カテゴリごとの記事一覧ページに遷移しましょう。
次のようなコードを header.astro として components/ に作成します。
---
import { getCollection } from "astro:content";
const parentCategories = await getCollection("parentCategories");
const childCategories = await getCollection("childCategories");
const groupedCategories = parentCategories.map((parent) => {
const children = childCategories.filter((child) =>
child.data.parentCategories?.some((p) => p.id === parent.id),
);
return { parent, children };
});
---
<header>
<a href="/">
テストページ
</a>
<nav aria-label="ナビゲーションヘッダー">
<ul>
{
groupedCategories.map((group) => (
<li>
<a href={`/category/${group.parent.id}`}>{group.parent.data.categoryName}</a>
{group.children.length > 0 && (
<ul>
{group.children.map((child) => (
<li>
<a href={`/category/${child.id}`}>
{child.data.categoryName}
</a>
</li>
))}
</ul>
)}
</li>
))
}
</ul>
</nav>
</header>このヘッダーのフロントマター内ではすべての親カテゴリに対して .map() を回し、その親カテゴリを参照している子カテゴリを .filter() で抽出しています。これにより、ナビゲーション用の「親カテゴリとその配下の子カテゴリ」というグループ化されたデータ(groupedCategories)を作成しています。
最後に、このヘッダーと、これまで作ってきたカテゴリーごとの一覧ページの共通部分を Layout.astro で書き、layouts/ に保存します。
---
import Header from "../components/header.astro";
---
<!doctype html>
<html lang="ja">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width" />
<title>TestPage</title>
</head>
<body>
<Header />
<slot />
</body>
</html>
<style>
html,
body {
margin: 0;
width: 100%;
height: 100%;
}
</style>
あとはトップページの index.astro と記事本体ページの article/[id].astro を次のように準備してあげれば準備は万端です!
---
import Layout from "../layouts/Layout.astro";
---
// トップページのindex.astro
<Layout>
<div>
<h1>トップページ</h1>
</div>
</Layout>---
import { getCollection } from "astro:content";
import Layout from "../../layouts/Layout.astro";
const { id } = Astro.params;
export async function getStaticPaths() {
const articles = await getCollection("articles");
return articles.map((article) => ({
params: { id: article.id },
}));
}
const articles = await getCollection("articles");
const article = articles.find((article) => article.id === id);
if (!article) {
return Astro.redirect("/404");
}
---
// 記事本体ページにあたるarticle/[id].astro
// article/ はcategory/ と同じ階層に作成してください
<Layout>
<h1>{article.data.title}</h1>
<article set:html={article.data.content}></article>
</Layout>
最後にターミナルで npm run dev をし、localhost で確認すると...
「カテゴリごとの記事一覧ページ」へのリンクを持ったトップページが表示できました!
では、今回は「親カテゴリ1」の記事一覧ページに遷移してみましょう。
「親カテゴリ1」を親にもつ子カテゴリは「子カテゴリ1,4,5」ですから、これらをカテゴリのうちのひとつとしてもつ「テスト記事1,2,3」が表示されれば成功です。
結果は...
成功です!
が、このままでは「記事が全部表示されてるだけじゃないの?」となってしまいますよね。
では、「親カテゴリ3」をみてみましょう。
「親カテゴリ3」を親にもつ子カテゴリは「子カテゴリ3,4」ですから、これらをカテゴリのひとつとしてもつ「テスト記事2,3」のみが表示されれば成功です。
結果は...
成功です!
では、子カテゴリについても見てみましょう。
「子カテゴリ1」をカテゴリとして持っているのは「テスト記事1」だけですので、「子カテゴリ1」の記事一覧ページに「テスト記事1」だけが表示されれば成功ですね。結果は...
成功です!
これで、「親カテゴリごとの記事一覧ページ」も「子カテゴリごとの記事一覧ページ」も pages/category/[id].astro ひとつで実現できました!
続・ちょっと応用編
実装手順 > ちょっと応用編で示した「category/親カテゴリ/子カテゴリ」の動的ルーティングが正しく機能しているのかを確認していきましょう。
そのためには components/ に作成した header.astro の中身を少々変更する必要があります。
変更を加えるのは <nav> の中身です。
<nav aria-label="ナビゲーションヘッダー">
<ul>
{
gropedCategories.map((group) => (
<li>
<a href={`/category/${group.parent.id}`}>{group.parent.data.categoryName}</a>
{group.children.length > 0 && (
<ul>
{group.children.map((child) => (
<li>
<a href={`/category/${group.parent.id}/${child.id}`}>
{child.data.categoryName}
</a>
</li>
))}
</ul>
)}
</li>
))
}
</ul>
</nav>
はい、子カテゴリのリンク先を「親カテゴリ/子カテゴリ」の形で設定しました。
このように反映してあげないと、せっかく階層構造にしたのに404ページに飛ばされてしまいます。
設定が終わったら、「親カテゴリ3」のリンク先をみてみましょう。
すると...
これまでと同様に「category/親カテゴリ」のurlが生成されていますね。では、「子カテゴリ4」をみてみましょう。結果は...
成功です!urlは「category/親カテゴリ/子カテゴリ」となっていて、ページも正しく生成されています。
これで、「親カテゴリごとの記事一覧ページ」も「子カテゴリごとの記事一覧ページ」も pages/category/[...id].astro ひとつで実現できました!
おわりに
今回は、microCMSの「コンテンツ参照機能」とAstroの標準的な機能だけで「カテゴリや執筆者ごとの記事一覧ページ」を実装する方法を紹介しました。
今回はカテゴリのidでフィルタリングを行いましたが、例えばこれを執筆者に変更すれば、同様に「執筆者ごとの記事一覧ページ」を実現できます。
また、今回用いたAstroは参照関係の解決をビルド時に行うため、AstroとmicroCMSを組み合わせることはユーザーに素早い画面表示を提供したい際にうってつけです。
是非この機会にAstroとmicroCMSを用いたメディアサイト作成にチャレンジしてみてください。


