microCMS

microCMSで親子関係をもつページを実装してみよう

高宮 竜太

こんにちは!カスタマーエンジニアの高宮です。

コーポレートサイトやサービスサイトでは、「機能」「料金」「会社情報」など、基本となる情報を掲載するページが必要です。これらのページは頻繁な更新は必要ないものの、時には内容の修正や、新しいページの追加が必要になることもあります。
また、「機能」子ページに各機能の詳細ページを設置したり、各ページでデザインの異なるレイアウトを使用したりする必要もあります。

本記事では、microCMSで親子関係のあるページ構造の実現と、コンテンツ管理者でも柔軟にページのレイアウトを作成するための設計・実装方法について解説します。

はじめに

今回はmicroCMSのサービスサイトを例に取って「親ページ・子ページの関係を持つページ構造」と「複数のデザインパーツを組み合わせたレイアウト作成」を管理画面の操作で実現するための設計・実装方法について紹介します。
ブログの全体像を表す図解。ウェブサイトのページ階層構造が視覚的に示されており、トップページ(https://example.com)を起点に、機能ページ(https://example.com/feature)、コンテンツAPIページ(https://example.com/feature/content-api)とそのGETページ、マネジメントAPIページ(https://example.com/feature/management-api)とそのGETページ、料金プランページ(https://example.com/pricing)へと枝分かれしている。それぞれのページのスクリーンショットが関連付けられて表示されている。
なお、CSSやデザインなど見た目に関する説明は含みません。デザインについては最低限の実装にとどめています

1. APIスキーマの設計

はじめにAPIスキーマを設定します。
今回は1つのAPIですべての親子ページを管理する設計にします。
ページ:pages(リスト形式)

  • slug:スラッグ(テキストフィールド)
  • title:タイトル(テキストフィールド)
  • parent:親ページ(コンテンツ参照 - ページAPIを参照)
  • layout:レイアウト(繰り返しフィールド)

microCMSのAPIスキーマ設定画面。各フィールドにフィールドID(例: slug, title, parent, layout)、表示名(例: スラッグ、タイトル、親ページ、レイアウト)、および種類(例: テキストフィールド、コンテンツ参照)を設定している
それぞれのフィールドについて説明します。

slug:スラッグについて

スラッグはURLを生成する際に利用します。
本来、URLの生成にはコンテンツIDを利用するのが一般的です。ですが、コンテンツIDはAPI内でユニークな値である必要があるため、URLの最下層のパスが同じ値のケースに対応できません。

対応できない例:
「https://example.com/feature/content-api/get」「https://example.com/feature/management-api/get
URLの最下層のパスに利用する値を登録するために、重複が可能なテキストフィールドを設定しています。

parent:親ページについて

親子関係の構造を実現するために、コンテンツ参照フィールドを利用します。
コンテンツを登録する際に親となるコンテンツを選択します。子のコンテンツにリクエストした際、以下のようなデータが返却されます。。

// 例:コンテンツAPIのGETの情報を取得
{
  "id": "w2emkbzz0q9z",
  "title": "GET",
  "slug": "get",
  "parent": {
    "id": "30u5c-uij",
    "title": "コンテンツAPI",
    "slug": "content-api",
    "parent": {
      "id": "0yptv8zp9a1u",
      "title": "機能",
      "slug": "feature"
    }
  }
}

この値を使って、実装側でルーティングの制御をすれば管理画面の操作で以下のような構造のサイトを管理できます。

トップページ(/)
├──料金プラン(/pricing)
└──機能(/feature)
      ├── コンテンツAPI(/feature/content-api)
      │   └── GET(/feature/content-api/get)
      ├── マネジメントAPI(/feature/management-api)
      │   └── GET(/feature/management-api/get)
      └── スキーマ(/feature/schema)

layout:レイアウトについて

レイアウトはコンテンツの運用者がページのレイアウトを作成するためのフィールドで、繰り返しフィールドを利用します。
繰り返しフィールド内で利用するカスタムフィールドについては次のセクションで説明します。

2. レイアウトの設計

繰り返しフィールド・カスタムフィールドを利用して、レイアウトを作成するための設計を説明します。

カスタムフィールドの設定例

今回は例として4つのレイアウトタイプを設定します。これらを選択したり組み合わせたりすることでレイアウトを作成します。

  1. リッチエディタ
  2. デザインパーツ(画像+テキストの2カラム」や「カードグリッド」など)
  3. ページ全体のレイアウト
  4. HTML

※実際には要件やデザインに応じて設定してください。
microCMSのカスタムフィールド選択画面。リッチエディタ、2カラム(画像+テキスト)、カードグリッド、HTML入力、トップページ参照が表示されている。
カスタムフィールドの基本的な作成方法についてはドキュメントを参照してください。

1. リッチエディタ

見出し、本文、画像など、基本的なレイアウトを作成するためのフィールドです。
スキーマはリッチエディタのみを設定しています。
microCMSのカスタムフィールドのスキーマ編集画面。フィールドIDに'richEditor'、表示名に'リッチエディタ'が設定されており、種類としてリッチエディタが選択されている。必須項目の設定がオンになっている。
主な用途としては文章中心のコンテンツやシンプルな情報掲載ページを作成する際に利用します。
GET APIのドキュメントページの一部。リスト形式とオブジェクト形式でのAPI利用について説明されており、リクエストヘッダーやクエリパラメータの設定方法が記載されている。リクエストヘッダーには「X-MICROCMS-API-KEY」を指定する必要があり、クエリパラメータとして「draftKey」「limit」「offset」などの使用方法が詳細に説明されている。

2. デザインパーツ

リッチエディタだけでは表現できないレイアウトを作成するためのフィールドです。パーツを組み合わせてレイアウトを作成します。
今回の例では「2カラム(画像+テキスト)」や「カードグリッド」を作成していますが、要件に応じてデザインパーツを追加することで柔軟なレイアウトを作成できます。

「2カラム(画像+テキスト)」のスキーマは以下のように設定しています。

  • image:画像(画像フィールド)
  • heading:見出し(テキストフィールド)
  • text:テキスト(テキストフィールド)
  • imageRight:画像を右側に配置する(真偽値フィールド)

microCMSのカスタムフィールドのスキーマ設定画面。フィールドIDとして'image'(画像)、'heading'(見出し)、'text'(テキスト)、'imageRight'(画像を右側に配置する)が設定され、それぞれの種類として画像、テキストフィールド、テキストエリア、真偽値が選択されている。'imageRight'以外のフィールドで必須項目がオンになっている。
「カードグリッド」のスキーマは以下のように設定しています。

  • cards:カードグリッド(繰り返しフィールド - カードを選択

「カードグリッド」から選択している「カード」のカスタムフィールドは以下のように設定しています。

  • image:画像(画像フィールド)
  • title:タイトル(テキストフィールド)
  • text:テキスト(テキストエリア)
  • link:遷移先URL(テキストフィールド)

microCMSのカスタムフィールドのスキーマ設定画面。カードグリッドとカードのスキーマ設定例。カードグリッドは繰り返しフィールドで、カードを選択することで複数のカードを設定可能にしている。カードには'image'(画像)、'title'(タイトル)、'text'(テキスト)、'link'(遷移先URL)の各フィールドが設定されており、'link'以外の項目が必須項目がオンになっている。
定義したカスタムフィールドを組み合わせることで以下のようなレイアウトを作成できます。
Webページの実際の表示画面。2カラム(画像+テキスト)のセクションに大きな「Sample」の画像が表示され、その下に「例えばこんな使い方・活用方法」という見出しとともに、カードグリッドが表示されている。カードグリッドには3つのカードが並び、それぞれ「Sample」の画像と「活用方法1」「活用方法2」「活用方法3」のタイトルが付けられている。ページ下部には青い背景に「まずは、無料で試してみましょう。」というテキストと「無料で始める」のボタンが配置されている。
具体的なフィールドの設定方法についてはmicroCMSのブログ「繰り返しフィールド・カスタムフィールドをマスターしよう」をご参照ください。

3. ページ全体のレイアウト

ページの全体的なレイアウトを設定するためのフィールドです。
主な用途としてはデザインパーツやリッチエディタを組み合わせても作成できないページ固有のデザインを作成する際に利用します。
microCMSのランディングページ例。ヘッダーに「コンテンツ管理をアップデートしよう」というキャッチフレーズと「チュートリアルを始める」「無料トライアルを開始する」のボタンが表示されている。下部には「管理画面から入稿、APIで取得」として、予約公開、画像API、権限管理、レビューといった特徴がアイコン付きで説明されている。その下には事例インタビューのセクションがあり、複数のカードに企業名と事例が記載されている。
こちらはカスタムフィールドからコンテンツ参照を利用し、別のAPIで定義したオブジェクト形式のデータを参照しています。
microCMSのカスタムフィールドのスキーマ編集画面。フィールドIDが'relation'、表示名が'トップページ参照'と設定されている。種類はコンテンツ参照(トップページ)が選択されており、別APIで定義したトップページのAPIが参照されている。必須項目はオフになっている。
トップページのAPI(オブジェクト形式)は以下のように定義しています。

  • mvImage: メインビジュアル - 画像(画像フィールド)
  • mvHeading: メインビジュアル - 見出し(テキストフィールド)
  • mvLead: メインビジュアル - リード文(テキストエリア)
  • featureHeading: 機能 - 見出し(テキストフィールド)
  • featureDescription: 機能 - 説明文(テキストエリア)
  • featureList: 機能 - リスト(繰り返しフィールド)
  • caseHeading: 事例 - 見出し(テキストフィールド)
  • caseList: 事例 - リスト(複数コンテンツ参照 - 事例)

microCMSのAPIスキーマ設定画面。フィールドID、表示名、種類を設定する項目が並んでおり、具体的なフィールドとして'mvImage'(メインビジュアル・画像)、'mvHeading'(メインビジュアル・見出し)、'mvLead'(メインビジュアル・リード文)、'featureHeading'(機能・見出し)、'featureDescription'(機能・説明文)、'featureList'(機能・リスト)、'caseHeading'(事例・見出し)、'caseList'(事例・リスト)などが設定されている。すべての項目は必須項目がオンに設定されている。
コンテンツ参照を利用しなくてもカスタムフィールドで直接設定できますが、APIのフィールド数上限を考慮して別APIで管理する方法を選択しています。

具体的なフィールドの設定方法については「microCMSのカスタムフィールドを使ってランディングページを作ろう」をご参照ください。

4. HTML

既存コンポーネントでは実現できない場合にHTMLを入力してレイアウトを作成するためのフィールドです。
スキーマにはテキストエリアのみを設定しています。
microCMSのカスタムフィールドのスキーマ編集画面。フィールドIDに'htmlEditor'、表示名に'HTML入力'が設定されており、種類としてテキストエリアが選択されている。必須項目の設定がオンになっている。
HTMLについては技術的な知識が必要なことや保守性の観点からできるだけ利用を避けることを推奨します。

3. フロントエンドの実装方法

今回はNext.jsを利用してルーティングを設定します。

全体像

/app/[...slug]/page.tsx

import { notFound } from 'next/navigation'
import {
  GridCard,
  HTML,
  ImageTextBlock,
  RichEditor,
  TopPage,
} from '../_components/custom-fields/index'
import { client } from '../_libs/microcms'
import type { Page as PageData } from '../_types'

export async function generateStaticParams() {
  return await generateAllPaths()
}

export default async function Page({
  params,
}: { params: Promise<{ slug: string[] }> }) {
  const { slug } = await params
  const contents = await getPageBySlug(slug)

  if (!contents) return notFound()

  return contents.layout.map((field, index) => {
    switch (field.fieldId) {
      case 'richEditor':
        return <RichEditor key={index} {...field} />
      case 'imageTextBlock':
        return <ImageTextBlock key={index} {...field} />
      case 'gridCard':
        return <GridCard key={index} {...field} />
      case 'topPage':
        return <TopPage key={index} {...field} />
      case 'html':
        return <HTML key={index} {...field} />
      default:
        return <div key={index}>Unknown field</div>
    }
  })
}

const generateAllPaths = async (
  parentId?: string,
  basePath: string[] = [],
): Promise<{ slug: string[] }[]> => {
  const { contents } = await client.getList<PageData>({
    endpoint: 'pages',
    queries: {
      filters: parentId ? `parent[equals]${parentId}` : 'parent[not_exists]',
      fields: 'id,slug',
    },
  })

  if (contents.length === 0) return []

  const paths: { slug: string[] }[] = []

  for (const page of contents) {
    const currentPath = [...basePath, page.slug]
    paths.push({ slug: currentPath })

    const childPaths = await generateAllPaths(
      page.id,
      currentPath,
    ) /* 再帰呼び出し */

    paths.push(...childPaths)
  }

  return paths
}

const getPageBySlug = async (
  paths: string[],
  depth = 0,
): Promise<PageData | null> => {
  if (depth >= paths.length) return null

  const { contents } = await client.getList<Page>({
    endpoint: 'pages',
    queries: { filters: `slug[equals]${paths[depth]}`, depth: 2 },
  })

  const matchedPage = contents.find(
    page => depth === 0 || page.parent?.slug === paths[depth - 1],
  )

  if (matchedPage) {
    if (depth === paths.length - 1) return matchedPage
    return await getPageBySlug(paths, depth + 1)
  }

  return null
}

ルーティングについて

generateAllPaths関数

generateAllPaths関数は、microCMSから取得したデータを元に、全てのページのパスを生成するための関数です。
生成したパスはNext.jsのgenerateStaticParamsで使用され、静的なページを生成します。

export async function generateStaticParams() {
  const paths = await generateAllPaths()
  return paths
}
// ...省略
const generateAllPaths = async (
  parentId?: string,
  basePath: string[] = [],
): Promise<{ slug: string[] }[]> => {
  const { contents } = await client.getList<PageData>({
    endpoint: 'pages',
    queries: {
      filters: parentId ? `parent[equals]${parentId}` : 'parent[not_exists]',
      fields: 'id,slug',
    },
  })

  if (contents.length === 0) return []

  const paths: { slug: string[] }[] = []

  for (const page of contents) {
    const currentPath = [...basePath, page.slug]
    paths.push({ slug: currentPath })

    const childPaths = await generateAllPaths(
      page.id,
      currentPath,
    ) /* 再帰呼び出し */

    paths.push(...childPaths)
  }

  return paths
}

この関数は親ページから子ページへと再帰的にデータを取得し、各ページのパスを配列で返却します。
今回は以下のようにgenerateStaticParamsの仕様に合わせた値を返却していますが、利用しているフレームワークに合わせたデータを返却してください。

[
  { slug: [ 'feature' ] },
  { slug: [ 'feature', 'content-api' ] },
  { slug: [ 'feature', 'content-api', 'get' ] },
  { slug: [ 'feature', 'management-api' ] },
  { slug: [ 'feature', 'management-api', 'get' ] }
]

この値をgenerateStaticParamsで返却することでビルド時に静的ページを生成します。

// 生成されるページ
機能(/feature)
├── コンテンツAPI(/feature/content-api)
│   └── GET(/feature/content-api/get)
└── マネジメントAPI(/feature/management-api)
     └── GET(/feature/management-api/get)

getPageBySlug関数

getPageBySlug関数は、URLのパスに対応するコンテンツを取得するための関数です。

const getPageBySlug = async (
  paths: string[],
  currentIndex = 0,
): Promise<Page | null> => {
  if (currentIndex >= paths.length) return null

  const { contents } = await client.getList<Page>({
    endpoint: 'pages',
    queries: { filters: `slug[equals]${paths[currentIndex]}`, depth: 2 },
  })

  const matchedPage = contents.find(
    page => currentIndex === 0 || page.parent?.slug === paths[currentIndex - 1],
  )

  if (matchedPage) {
    if (currentIndex === paths.length - 1) return matchedPage
    return await getPageBySlug(paths, currentIndex + 1)
  }

  return null
}

URLのパス(slug)からコンテンツを取得する場合、単純にslugでの絞り込みでは不十分です。
例えば以下のような構造を持つサイトを考えてみます。

機能(/feature)
├── コンテンツAPI(/feature/content-api)
│   └── GET(/feature/content-api/get)
└── マネジメントAPI(/feature/management-api)
     └── GET(/feature/management-api/get

この場合、「/feature/content-api/get」と「/feature/management-api/get」のように、同じslug(get)を持つコンテンツが複数存在します。単純に「slugが"get"に一致するページ」という条件だけでは1つのコンテンツに絞り込むことができません。

そこで、getPageBySlug関数では、パスを順番に辿りながら親子関係を考慮してコンテンツを特定します。この処理により、同じスラッグを持つコンテンツが存在しても、パス全体の構造を考慮して正しいコンテンツを取得することができます。

レンダリングについて

カスタムフィールドの出力

コンテンツ毎に登録したレイアウトを出力します。

return contents.layout.map((field, index) => {
    switch (field.fieldId) {
      case 'richEditor':
        return <RichEditor key={index} {...field} />
      case 'imageTextBlock':
        return <ImageTextBlock key={index} {...field} />
      case 'gridCard':
        return <GridCard key={index} {...field} />
      case 'topPage':
        return <TopPage key={index} {...field} />
      case 'html':
        return <HTML key={index} {...field} />
      default:
        return <div key={index}>Unknown field</div>
    }
  })

contents.layoutにはコンテンツごとに設定されたカスタムフィールドの情報が含まれています。カスタムフィールドはfieldIdで種類が識別されるので、fieldIdの値に基づいて対応するコンポーネントを出力します。CSSついてはそれぞれのコンポーネント内で実装することで柔軟なページ構成が実現できます。

4. 管理画面の整理と運用方法

ここまで、APIスキーマの設計とフロントエンドの実装方法について解説しました。最後に管理画面の整理と運用方法について説明します。

今回の設計の場合、すべての親子ページが1つのAPIで管理されます。そのため、コンテンツが増加した場合、管理画面を整理をしないと、どのコンテンツがどのページに紐づいているのかがわかりづらくなってしまいます。

対処策としては、登録したコンテンツは、以下のように階層に沿って手動で並び替えることを想定しています。
microCMSのページ一覧画面での操作を示すGIF。先頭のコンテンツを任意の位置に並べ替えている
その上で、この設計には以下の課題があります。

  1. 新たに公開されたコンテンツは自動で一覧の最上部に表示される
  2. 階層情報の把握がしづらい

1. 新たに公開されたコンテンツは自動で一覧の最上部に表示される

本来、親ページを設定した場合は、その親ページの下に表示したいケースが多いかと思います。しかし、microCMSの場合は新規で公開したコンテンツは一番上に表示されます。
microCMSのページ一覧画面。新規登録されたアイテムがリストの一番上に表示されており、赤い矢印で表示させたい位置を指し示している。
新規にコンテンツ作成した際は、合わせて並べ替えを行ってください。

2. 階層情報の把握がしづらい

親子構造については親ページとなるページのスラッグを表示することで、ある程度の可視化は可能です。ただし、データとしては親ページのコンテンツしか紐づいていないため、先祖までの階層情報を可視化するには、それ用のデータを登録する必要があります。
microCMSの一覧画面。親子関係の構造を示す例で、赤い枠で囲まれた部分に子(content-api)が表示され、親(feature)の情報が表示されているが、さらにその先祖(feature)の情報は表示されていない。
例えば、テキストフィールドを定義して先祖までのパスの情報を手動で入力する方法が考えられます。
microCMSの一覧画面。各ページの階層(ページパス)が表示されており、トップページ(/)、機能(/feature)、コンテンツAPI(/feature/content-api)、GET(/feature/content-api/get)、PATCH(/feature/content-api/patch)などが表示されている。
または、タイトルに記号などを追加して階層を表現する方法も考えられます。WordPressに慣れている人は見やすいかもしれません。
microCMSの一覧画面。階層(ページタイトル)の列において、接頭詞「ー」が使用され、ページの階層構造が視覚的に示されている。例えば、トップページには接頭詞がなく、次の階層として「機能」に1つの「ー」、その下の階層「コンテンツAPI」に2つの「ー」、さらにその下の「GET」や「PATCH」には3つの「ー」が付けられている。
ただし、これらの値についてはあくまでも管理画面を見やすくするための情報であり、フロントエンドでの利用は想定していません。
どうしても階層情報がわかりづらいと感じる場合は、検討してみてください。

さいごに

本記事では、親子関係を持つコンテンツの管理とレイアウト作成するための設計と実装方法について解説しました。今回は1つのAPIを利用したり、コンテンツ参照フィールドを利用したりする方法で実装しました。必要なAPIが少なくなるというメリットがある一方で、管理画面が煩雑化するなどのデメリットもあります。

今回紹介した方法以外にも「ページごと・1階層ごとにAPIを分ける」「テキストフィールドに入れた階層情報をもとにルーティングする」などの方法も考えられます。サイトの規模などの要件以外にも運用者の知識によっても最適解が異なるので、この記事をもとに最適解を見つけてみてください。

以下のリポジトリにソースコードも公開していますので、ぜひ参考にしてみてください。
https://github.com/wasabi-tr/microcms-nested-pages-demo

まずは、無料で試してみましょう。

APIベースの日本製ヘッドレスCMS「microCMS」を使えば、 ものの数分でAPIの作成ができます。

microCMSを無料で始める

microCMSについてお問い合わせ

初期費用無料・14日間の無料トライアル付き。ご不明な点はお気軽にお問い合わせください。

お問い合わせ

microCMS公式アカウント

microCMSは各公式アカウントで最新情報をお届けしています。
フォローよろしくお願いします。

  • X
  • Discord
  • github

ABOUT ME

高宮 竜太
元小学校教員。Web制作会社のフロントエンドエンジニアを経て現在はmicroCMSのカスタマーエンジニアをしています。趣味はバス釣り🎣