microCMSからCraft Cross CMSへのリプレイスで学んだ「データ構造」の最適化
はじめに:なぜ今、CMSを移行するのか
本技術ブログを運営するエンジニアチーム「BEMA」チームでは、運用プロセスの最適化とAI機能の活用を目的として、microCMSからCraft Cross CMSへの移行プロジェクトを実施しました。
Craft Cross CMS とは、MAツールの「KARTE」を提供しているプレイド社が、ヘッドレスCMS「Newt」(ニュート)の資産譲渡により開発した新しいヘッドレスCMSです。生成AIやAIエージェントによる参照が可能である点や、既存のKARTE機能と連携できる点が大きな強みです。
今回は、実際にCMS移行に伴うコード改修を行う中で直面した課題や今後の開発で気をつけようと思った点を共有します。
移行を始めて困った箇所
1.microCMSのSDKからの移行
microCMSでは、microCMSのAPIをより簡単に、そして安全に扱うために microCMS JavaScript SDKというライブラリが用意されています。
公式ドキュメント(GitHub)
公式ヘルプ・ブログ
microCMS JavaScript SDKでは、以下3つの処理が自動化されています。
fetch処理:fetch() 関数を書かなくても、メソッド1つで通信ができる
認証:APIキーを1度設定すれば、毎回リクエストヘッダーに書く必要がない
URL構築:limit や filters などのクエリパラメータをオブジェクト形式で直感的に書ける
microCMSの専用SDKを用いたデータ取得方法
export const getAllContentList = async <T>(
apiName: string,
queries: MicroCMSQueries = {}
): Promise<Array<T & MicroCMSContentId & MicroCMSDate>> => {
const contents = await client.getAllContents<T>({
endpoint: apiName,
queries
})
return contents
}移行前での、全データを取得する関数はこのような形です。 client.getAllContentsは、microCMS公式SDKが提供するメソッドで、APIの取得上限(100件)を超えて全コンテンツを一括取得できる関数です。
しかし、Craft Cross CMS移行による影響で、 microCMS JavaScript SDKのライブラリは使用できないため、ライブラリで自動化されているような処理を確認し修正する必要がありました。 移行プロジェクト自体が初めてで、モダンフレームワークの実装経験も浅かった自分にとってライブラリが解決してくれていた複雑な処理を、自分の手でどう紐解き再構築すべきか。解決の糸口を求めて参考情報を探し、依存関係にある機能を一つずつ洗い出す作業に、実装がスムーズに進んでいけるかなどの不安を感じていました。
2.CMS間でデータ構造が全く違う
microCMSのデータでは、カテゴリーやタグ、著者データに「名前」やその他の詳細情報も含まれていました。
APIプレビューで取得した記事のデータ構造
しかし、Craft Cross CMSのデータ構造を確認してみると、カテゴリーやタグ、著者データはID(文字列)のみしか返ってこないことがわかります。
Craft Cross CMSのデータ構造
影響箇所
移行前のTOPページや記事の詳細ページでは、microCMSの参照先のデータが最初から展開されて返ってきていたため問題なく表示されていましたが、参照データがIDのみで返ってくるCraft Cross CMSではエラーとなってしまい、ページが表示されなくなってしまいます。
<Card
articleId={article.id}
title={article.title}
url={`/articles/${article.id}`}
category={article.category.name}
categoryId={article.category.id}
author={{ id: article.author.id, image: article.author.image.url,
name: article.author.name }}
tags={article.tags}
/>上記コードの category={article.category.name}を例に考えてみると、
移行前:article.category の中に { "id": "1", "name": "ニュース" } というオブジェクトが入っている。
移行後: article.category には "12345" という ID(文字列) だけが入っている、もしくは { "id": "12345" } という最小限のオブジェクトしか入っていない。
結果:article.category.name を参照しても、name という項目自体が存在しない(undefined)ため、画面には何も出ないか、エラーで停止したりします。
authorやtagsも同様です。idしか返らない、または項目自体が存在しない点でエラーがでたり、画面が真っ白になる現象に直面しました。
そのため、解決に向けてKARTEにあるJSONプレビューでのデータ構造の確認と、元のmicroCMSとのデータ構造の違いを確認。元のデータ構造と同じように、参照先のデータが最初から展開されて返ってくるような結合処理の実装が必要なのではないかと考えました。
解決策:移行に合わせた対応
1.データの取得には手動で制御
export const getAllContentList = async (modelId: string): Promise<any> => {
const limit = 100
async function getContents(beforeData: any[], offset: number): Promise<any[]> {
let items = []
try {
const contents = await fetch( `https://your-project-subdomain.cdn-api.karte.io/beta/cms/content/list?modelId=${modelId}&skip=${offset}&limit=${limit}`,
{
headers: {
authorization: `Bearer ${import.meta.env.CCC_TOKEN}`,
accept: 'application/json',
'content-type': 'application/json'
},
method: 'GET'
}
)
if (contents.status === 429) {
await new Promise((resolve) => setTimeout(resolve, 1000))
return await getContents(beforeData, offset)
}
if (!contents.ok) {
throw new Error('ステータス:' + contents.status)
}
items = (await contents.json()).items
} catch (e) {
items = []
}
if (items === undefined || items.length <= 0) {
return beforeData
}
const data = items.map((item: any) => {
return formatContent(item)
})
console.log(`モデルID [${modelId}]: ${offset}件から${items.length}件を取得しました`)
await new Promise((resolve) => setTimeout(resolve, 1000))
return await getContents([...beforeData, ...data], limit + offset)
}
let data: any[] = []
try {
data = ((await getContents([], 0)) ?? []).filter(
(banner) => banner.sys?.raw?.publishedAt !== undefined && banner.sys?.raw?.publishedAt !== null
)
} catch (e) {
console.error(e)
}
return data
}移行後のデータ取得を行う関数のコード全体はこのような形になります。
コードの説明
データ取得の手動制御箇所
const contents = await fetch(
`https://your-project-subdomain.cdn-api.karte.io/beta/cms/content/list?modelId=${modelId}&skip=${offset}&limit=${limit}`,
{
headers: {
authorization: `Bearer ${import.meta.env.CCC_TOKEN}`,
accept: 'application/json',
'content-type': 'application/json'
},
method: 'GET'
}
)上記は、KARTEのデベロッパーポータルを参考に作成した箇所で、Fetch APIを用いてHTTPリクエストを送信してデータを取得するAPI通信部分のコードです。
クエリパラメータについて
`https://your-project-subdomain.cdn-api.karte.io/beta/cms/content/list?modelId=${modelId}&skip=${offset}&limit=${limit}`上記のURLは、エンドポイント(通信先の宛先となるURL部分)です。list?以降のコードからは、クエリパラメータと呼ばれ、サーバーに渡す「検索条件」や「取得条件」を指す部分になります。
modelId:コンテンツモデル(データ構造)を特定するユニークなID
skip:先頭から何件飛ばすかを指定
limit:1回のリクエストで取得する最大件数を指定
処理の流れとしては
1:offset が 0 なので、skip=0 で取得(1〜100件目)
2:offset に 100 を足して、skip=100 で取得(101〜200件目)
3:offset にまた 100 を足して、skip=200 で取得(201〜300件目)
このように、取得開始位置を後ろにずらして処理されていくイメージです。
リクエストヘッダーについて
headers: {
authorization: `Bearer ${import.meta.env.CCC_TOKEN}`,
accept: 'application/json',
'content-type': 'application/json'
},上記のリクエストヘッダーでは、認証情報やレスポンス形式、送信データ形式などを指す箇所です。
authorization:
Bearer ${import.meta.env.CCC_TOKEN}
指定したトークンを持っている人」に対してアクセスを許可するaccept: 'application/json'
サーバーからのレスポンスをJSON形式で受け取る'content-type': 'application/json'
送信データ形式はJSON形式とサーバーに伝えている箇所
2.記事データのマッピング
export const getArticlesWithDetail = async (): Promise<ArticleWithDetail[]> => {
// 全データを取得
const [articles, authors, categories, tags, jobTypes]: [Article[], Author[], Category[], Tag[], JobType[]] =
await Promise.all([
getPreviewContentList(ARTICLE_MODEL_ID)
getCachedContentList(AUTHOR_MODEL_ID),
getCachedContentList(CATEGORY_MODEL_ID),
getCachedContentList(TAG_MODEL_ID),
getCachedContentList(JOB_TYPE_MODEL_ID)
])
// 検索用のMapを作成
const authorsMap = new Map<string, Author>(authors.map((author) => [author.id, author]))
const categoriesMap = new Map<string, Category>(categories.map((category) => [category.id, category]))
const tagsMap = new Map<string, Tag>(tags.map((tag) => [tag.id, tag]))
const jobTypesMap = new Map<string, JobType>(jobTypes.map((jobType) => [jobType.id, jobType]))
// 記事に詳細情報を結合
const articlesWithDetail: ArticleWithDetail[] = articles.map((article) => {
const authorDetail: Author | undefined =
article.author !== undefined && article.author !== '' ? authorsMap.get(article.author) : undefined
const categoryDetail: Category | undefined =
article.category !== undefined && article.category !== '' ? categoriesMap.get(article.category) : undefined
const tagsDetail: Tag[] =
article.tags?.map((tagId) => tagsMap.get(tagId)).filter((tag): tag is Tag => tag !== undefined) ?? []
const jobTypeDetail: JobType | undefined =
authorDetail?.job_type !== undefined && authorDetail.job_type !== ''
? jobTypesMap.get(authorDetail.job_type)
: undefined
return {
...article,
authorDetail,
categoryDetail,
tagsDetail,
jobTypeDetail
}
})
return articlesWithDetail
}マッピング処理を行なっている関数のコード全体はこのような形になります。
コードの説明
1.サイト表示に必要なデータの一斉取得
const [articles, authors, categories, tags, jobTypes]: [Article[], Author[], Category[], Tag[], JobType[]] =
await Promise.all([
// ...各データの取得処理
])上記のコードは、サイト表示に不可欠な記事・著者・カテゴリ・タグ・職種の各データを取得している箇所です。データ取得の待機時間を最小限に抑えるためにも、複数の非同期リクエストを並列実行できるPromise.all を用いています。
また、下書き状態か本番公開状態かでデータの取得方法を切り替えています。
2.検索用Mapの作成
// 検索用のMapを作成
const authorsMap = new Map<string, Author>(authors.map((author) => [author.id, author]))
const categoriesMap = new Map<string, Category>(categories.map((category) => [category.id, category]))
const tagsMap = new Map<string, Tag>(tags.map((tag) => [tag.id, tag]))
const jobTypesMap = new Map<string, JobType>(jobTypes.map((jobType) => [jobType.id, jobType]))このコードは、記事に著者名やカテゴリ名を紐付ける際の検索処理を高速化することを目的としています。
なぜMap形式に変換したのか
配列のままデータを結合する場合、特定のデータ(例:IDが user_123 の著者)を探す際に、コンピュータは「1番目は違う、2番目も違う…」と先頭から順にチェックしなければなりません。そのため、データ量が増えるほど処理に時間がかかります(O(n))。
例:記事1件ごとに、100人の著者リストを端から探す場合
1記事目:著者リストを最大100回チェック
2記事目:著者リストを最大100回チェック
...
合計:1,000記事 × 100人 = 最大10万回のチェックが発生する
上記の処理を配列からMap形式に変換することで、IDを指定するだけで即座に目的のデータに辿り着くことができます。(Map形式とは、「キー(Key)と値(Value)をセットにして保存するデータ構造」のことです。)
準備:最初に著者100人を1回だけループしてMapを作成(100回の動作)。
結合:記事1,000件をループし、Mapから1回でデータを特定(1000回の動作)。
合計:100回(準備)+ 1000回(結合) = 1100回の動作で完了
もう少し分かりやすく表現すると、配列データの場合は、「部屋番号が記載されていないマンションから指定された番号の部屋を探し出す」といった内容です。Map形式の場合は、「玄関に部屋番号が書かれているマンションから探し出す」といった違いがあります。
無駄な処理を削減するためにもMap形式に変換してから処理を行なった方が、表示速度の観点やデータ数増化による処理速度の影響が低下しにくいといったメリットがあります。
3.データの結合処理
// 記事に詳細情報を結合
const articlesWithDetail: ArticleWithDetail[] = articles.map((article) => {
const authorDetail: Author | undefined =
article.author !== undefined && article.author !== '' ? authorsMap.get(article.author) : undefined
const categoryDetail: Category | undefined =
article.category !== undefined && article.category !== '' ? categoriesMap.get(article.category) : undefined
const tagsDetail: Tag[] =
article.tags?.map((tagId) => tagsMap.get(tagId)).filter((tag): tag is Tag => tag !== undefined) ?? []
const jobTypeDetail: JobType | undefined =
authorDetail?.job_type !== undefined && authorDetail.job_type !== ''
? jobTypesMap.get(authorDetail.job_type)
: undefined
return {
...article,
authorDetail,
categoryDetail,
tagsDetail,
jobTypeDetail
}
})上記のコードは、先ほどコードに追加したMapオブジェクトを用いて、記事データ内の著者やカテゴリのIDに詳細情報を紐づける処理を行なっています。
著者情報、カテゴリー情報の結合
const authorDetail: Author | undefined = article.author !== undefined && article.author !== '' ? authorsMap.get(article.author) : undefined
const categoryDetail: Category | undefined = article.category !== undefined && article.category !== '' ? categoriesMap.get(article.category) : undefined著者やカテゴリーのデータに関しては、IDがundefinedや空でない場合は、作成したauthorsMap、categoriesMapから詳細情報を取り出す処理になっています。
タグ情報の結合
const tagsDetail: Tag[] = article.tags?.map((tagId) => tagsMap.get(tagId)).filter((tag): tag is Tag => tag !== undefined) ?? []タグに関しては、記事に複数付与されている場合も考慮し、IDの配列を1つずつ取り出し、tagsMapを用いて対応するタグの詳細データを取り出しています。また、今回データが配列のため、filterメソッドを用いてundefinedのデータがないよう処理しています。
職種情報の結合
const jobTypeDetail: JobType | undefined =
authorDetail?.job_type !== undefined && authorDetail.job_type !== '' ? jobTypesMap.get(authorDetail.job_type) : undefined職種データに関しては、記事データの中に職種IDが存在しないため、紐付けた後の著者データ(authorDetail)の中にある職種IDを用いて、そこからjobTypesMapでデータを紐づけています。
記事データに統合
return {
...article,
authorDetail,
categoryDetail,
tagsDetail,
jobTypeDetail
}元々の記事データ(タイトル、本文、公開日、著者IDなど)をすべてコピーして展開し、それぞれの詳細情報を新しいプロパティに追加しています。
データの変化
結合前
{
"title": "Astroの解説",
"release_date": "2025-01-01T00:00:00.000Z",
"update_date": "2025-01-01T00:00:00.000Z",
"title": "Sample",
"description": "Sample",
...
"author": "user_123", // IDしかない
"category": "cat_abc" // IDしかない
"tags":[
"id": "tag_123",
"id": "tag_456"
]
}このように、詳細情報を結合することで、記事データを起点に関連リソースのプロパティに直接アクセスすることが可能になります。
結合後
{
"title": "Astroの解説",
"release_date": "2025-01-01T00:00:00.000Z",
"update_date": "2025-01-01T00:00:00.000Z",
"title": "Sample",
"description": "Sample",
...
"author": "user_123", // IDしかない
"category": "cat_abc" // IDしかない
"tags":[
"id": "tag_123",
"id": "tag_456"
]
"authorDetail":{"name": "佐藤", "image": "...", "slug": "user_sato",
etc..},
"categoryDetail": { "name": "技術", "slug": "tech", etc.. },
"tagsDetail":[
{ "id": "tag_123", "name": "Astro", "slug": "astro", etc.. },
{ "id": "tag_456", "name": "Frontend", "slug": "frontend" , etc..},
],
"jobTypeDetail": { "name": "エンジニア", etc..},
}このように、詳細情報を結合することで、記事データを起点に関連リソースのプロパティに直接アクセスすることが可能になります。
まとめ
CMSの移行に伴うコード改修を経験し、スムーズな移行を実現するために最も重要だと感じたのは、「旧環境固有の要素の確認」と「移行後のデータ構造の正確な把握」です。
具体的には、以下の2つのステップが重要です。
旧CMS固有のライブラリへの依存機能を洗い出し、新環境での代替手段を確保すること
実際のAPIレスポンス等を直接確認し、必須項目の有無やデータ構造の変化を比較すること
このプロセスを経て、把握した差異に基づきマッピング処理や不足値の補完を行う一連の流れこそが、移行時のコード改修において極めて重要であると気づくことができました。
今後は、この経験で培った「データ構造を正確に定義・制御する視点」をさらに深化させ、TypeScriptを用いた型安全で堅牢なコンポーネント設計や、React、Astroといったモダンフロントエンド技術を用いたパフォーマンスの高い実装に挑戦したいです。
What is BEMA!?
Be Engineer, More Agile


