こんにちは!カスタマーエンジニアの高宮です。
コーポレートサイトやサービスサイトでは、「機能」「料金」「会社情報」など、基本となる情報を掲載するページが必要です。これらのページは頻繁な更新は必要ないものの、時には内容の修正や、新しいページの追加が必要になることもあります。
また、「機能」子ページに各機能の詳細ページを設置したり、各ページでデザインの異なるレイアウトを使用したりする必要もあります。
本記事では、microCMSで親子関係のあるページ構造の実現と、コンテンツ管理者でも柔軟にページのレイアウトを作成するための設計・実装方法について解説します。
はじめに
今回はmicroCMSのサービスサイトを例に取って「親ページ・子ページの関係を持つページ構造」と「複数のデザインパーツを組み合わせたレイアウト作成」を管理画面の操作で実現するための設計・実装方法について紹介します。
なお、CSSやデザインなど見た目に関する説明は含みません。デザインについては最低限の実装にとどめています
1. APIスキーマの設計
はじめにAPIスキーマを設定します。
今回は1つのAPIですべての親子ページを管理する設計にします。
ページ:pages(リスト形式)
- slug:スラッグ(テキストフィールド)
- title:タイトル(テキストフィールド)
- parent:親ページ(コンテンツ参照 - ページAPIを参照)
- 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つのレイアウトタイプを設定します。これらを選択したり組み合わせたりすることでレイアウトを作成します。
- リッチエディタ
- デザインパーツ(画像+テキストの2カラム」や「カードグリッド」など)
- ページ全体のレイアウト
- HTML
※実際には要件やデザインに応じて設定してください。
カスタムフィールドの基本的な作成方法についてはドキュメントを参照してください。
1. リッチエディタ
見出し、本文、画像など、基本的なレイアウトを作成するためのフィールドです。
スキーマはリッチエディタのみを設定しています。
主な用途としては文章中心のコンテンツやシンプルな情報掲載ページを作成する際に利用します。
2. デザインパーツ
リッチエディタだけでは表現できないレイアウトを作成するためのフィールドです。パーツを組み合わせてレイアウトを作成します。
今回の例では「2カラム(画像+テキスト)」や「カードグリッド」を作成していますが、要件に応じてデザインパーツを追加することで柔軟なレイアウトを作成できます。
「2カラム(画像+テキスト)」のスキーマは以下のように設定しています。
- image:画像(画像フィールド)
- heading:見出し(テキストフィールド)
- text:テキスト(テキストフィールド)
- imageRight:画像を右側に配置する(真偽値フィールド)
「カードグリッド」のスキーマは以下のように設定しています。
- cards:カードグリッド(繰り返しフィールド - カードを選択)
「カードグリッド」から選択している「カード」のカスタムフィールドは以下のように設定しています。
- image:画像(画像フィールド)
- title:タイトル(テキストフィールド)
- text:テキスト(テキストエリア)
- link:遷移先URL(テキストフィールド)
定義したカスタムフィールドを組み合わせることで以下のようなレイアウトを作成できます。
具体的なフィールドの設定方法についてはmicroCMSのブログ「繰り返しフィールド・カスタムフィールドをマスターしよう」をご参照ください。
3. ページ全体のレイアウト
ページの全体的なレイアウトを設定するためのフィールドです。
主な用途としてはデザインパーツやリッチエディタを組み合わせても作成できないページ固有のデザインを作成する際に利用します。
こちらはカスタムフィールドからコンテンツ参照を利用し、別のAPIで定義したオブジェクト形式のデータを参照しています。
トップページのAPI(オブジェクト形式)は以下のように定義しています。
- mvImage: メインビジュアル - 画像(画像フィールド)
- mvHeading: メインビジュアル - 見出し(テキストフィールド)
- mvLead: メインビジュアル - リード文(テキストエリア)
- featureHeading: 機能 - 見出し(テキストフィールド)
- featureDescription: 機能 - 説明文(テキストエリア)
- featureList: 機能 - リスト(繰り返しフィールド)
- caseHeading: 事例 - 見出し(テキストフィールド)
- caseList: 事例 - リスト(複数コンテンツ参照 - 事例)
コンテンツ参照を利用しなくてもカスタムフィールドで直接設定できますが、APIのフィールド数上限を考慮して別APIで管理する方法を選択しています。
具体的なフィールドの設定方法については「microCMSのカスタムフィールドを使ってランディングページを作ろう」をご参照ください。
4. HTML
既存コンポーネントでは実現できない場合に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で管理されます。そのため、コンテンツが増加した場合、管理画面を整理をしないと、どのコンテンツがどのページに紐づいているのかがわかりづらくなってしまいます。
対処策としては、登録したコンテンツは、以下のように階層に沿って手動で並び替えることを想定しています。
その上で、この設計には以下の課題があります。
- 新たに公開されたコンテンツは自動で一覧の最上部に表示される
- 階層情報の把握がしづらい
1. 新たに公開されたコンテンツは自動で一覧の最上部に表示される
本来、親ページを設定した場合は、その親ページの下に表示したいケースが多いかと思います。しかし、microCMSの場合は新規で公開したコンテンツは一番上に表示されます。
新規にコンテンツ作成した際は、合わせて並べ替えを行ってください。
2. 階層情報の把握がしづらい
親子構造については親ページとなるページのスラッグを表示することで、ある程度の可視化は可能です。ただし、データとしては親ページのコンテンツしか紐づいていないため、先祖までの階層情報を可視化するには、それ用のデータを登録する必要があります。
例えば、テキストフィールドを定義して先祖までのパスの情報を手動で入力する方法が考えられます。
または、タイトルに記号などを追加して階層を表現する方法も考えられます。WordPressに慣れている人は見やすいかもしれません。
ただし、これらの値についてはあくまでも管理画面を見やすくするための情報であり、フロントエンドでの利用は想定していません。
どうしても階層情報がわかりづらいと感じる場合は、検討してみてください。
さいごに
本記事では、親子関係を持つコンテンツの管理とレイアウト作成するための設計と実装方法について解説しました。今回は1つのAPIを利用したり、コンテンツ参照フィールドを利用したりする方法で実装しました。必要なAPIが少なくなるというメリットがある一方で、管理画面が煩雑化するなどのデメリットもあります。
今回紹介した方法以外にも「ページごと・1階層ごとにAPIを分ける」「テキストフィールドに入れた階層情報をもとにルーティングする」などの方法も考えられます。サイトの規模などの要件以外にも運用者の知識によっても最適解が異なるので、この記事をもとに最適解を見つけてみてください。
以下のリポジトリにソースコードも公開していますので、ぜひ参考にしてみてください。
https://github.com/wasabi-tr/microcms-nested-pages-demo