microCMSのhobbyプランでAPI数制限と戦い、逃げ、そして救われた話。

2025-07-26

みなさんはmicroCMSを使っていますでしょうか?今回は職場の大学施設でのWebサイトのリプレイスでmicroCMSを使った時のお話です。
(もちろん職場の承諾を得ています。リポジトリもパブリックですし。)

技術構成

  • Next.js(App Router)
  • microCMS

当初以下のような構成を想定していました。
お知らせとブログそれぞれに一覧のページとカテゴリ(タグ)一覧ページと個別ページがあるmicroCMSのオーソドックスな構成です。

(pages)
├── blog
│   ├── [pageId]
│   │   └── page.tsx
│   ├── article
│   │   └── [postId]
│   │       └── page.tsx
│   └── page.tsx // /blog/article/1にリダイレクト
├── blogCategory
│   └── [tagId]
│       ├── [pageId]
│       │   └── page.tsx
│       └── page.tsx // /blogCategory/[categoryId]/1にリダイレクト
├── info
│   ├── [pageId]
│   │   └── page.tsx
│   ├── article
│   │   └── [postId]
│   │       └── page.tsx
│   └── page.tsx // /info/article/1にリダイレクト
├── infoCategory
│   └── [tagId]
│       ├── [pageId]
│       │   └── page.tsx
│       └── page.tsx // /infoCategory/[categoryId]/1にリダイレクト

APIが足りません!

開発当時microCMSのhobbyプランは3つのAPIまで無料でした(フラグ。気になる人はオチまで)

しかしこの構成で愚直に作ろうとすると以下のようになります。

  • ブログを管理するAPI
  • ブログのタグを管理するAPI
  • お知らせを管理するAPI
  • お知らせのタグを管理するAPI

hobbyプラン制限を超えて4つになってしまいます。
職場では無料構成が前提なのでこれではいけません。

思いついた屁理屈

ブログのカテゴリとお知らせのカテゴリはまとめてもいいよね

ここでいうカテゴリ(タグ)とは、例えば「青山」「相模原」などの本部と支部を区別したり、「ワークショップ」「動画広報」などの業務ごとに区別したりという用途を想定していました。ブログとお知らせのタグを分けなくても、そのカテゴリに関する「発信」としてまとめて考えてしまえばそれほど不自然ではないだろうということです。
分けた方がUXはいいかもしれませんが物はいいようということですね。

ということで以下のような構成にすることにしました。

API

  • ブログを管理するAPI
  • ブログとお知らせ共通のタグを管理するAPI
  • お知らせを管理するAPI

ページ構成

(pages)
├── blog
│   ├── [pageId]
│   │   └── page.tsx
│   ├── article
│   │   └── [postId]
│   │       └── page.tsx
│   └── page.tsx // /blog/article/1にリダイレクト
├── category
│   └── [tagId]
│       ├── [pageId]
│       │   └── page.tsx
│       └── page.tsx // /category/[categoryId]/1にリダイレクト
├── info
│   ├── [pageId]
│   │   └── page.tsx
│   ├── article
│   │   └── [postId]
│   │       └── page.tsx
│   └── page.tsx // /info/article/1にリダイレクト

実装例

少々特殊なのはカテゴリの一覧ページの取得ですね。
でかい関数になってるのでよしなに分けてください。

export const getCategoryArticleList = async (queries?: MicroCMSQueries) => {

	const allArticles: ArticleWithSourceType[] = [];
	const limit = 50;
	let total = 0;
	const filters = queries?.filters;

	try {
		const fetchArticles = async (
			endpoint: "info" | "blog",
			source: "info" | "blog",
		) => {
			let offset = 0;
			while (true) {
				const data = await client.getList<ArticleType>({
					endpoint,
					queries: { limit, offset, filters },
				});

				total = data.totalCount;
				allArticles.push(
					...data.contents.map(
						(article): ArticleWithSourceType => ({
							...article,
							source,
						}),
					),
				);

				if (offset + limit >= total) {
					break; // 全件取得済み
				}
				offset += limit; // 次のページに進む
			}
		};

        // info と blog の記事を逐次取得
		await fetchArticles("info", "info");
		await fetchArticles("blog", "blog");

		// ソートとページネーションの適用
		allArticles.sort((a, b) => {
			const dateA = new Date(a.publishedAt ?? a.updatedAt).getTime();
			const dateB = new Date(b.publishedAt ?? b.updatedAt).getTime();
			return dateB - dateA;
		});

		const totalCount = allArticles.length;
		const offsetQuery = queries?.offset || 0;
		const limitQuery = queries?.limit || LIMIT;
		const paginatedArticles = allArticles.slice(
			offsetQuery,
			offsetQuery + limitQuery,
		);

		return {
			contents: paginatedArticles,
			totalCount,
		};
	} catch (err) {
		console.error("Error fetching articles:", err);
		throw err;
	}
};

blogとinfoのAPIを叩いて、blogの記事かinfoの記事なのかが後で区別できるようにタグをつけて配列に貯めていき、その後日付順ソートとページベーションのための分割計算を行います。

あとは記事の個別ページでcategoryの一覧から遷移してきた場合に、そのタグに応じてinfoかblogのリンクを設定すればOKです。

export default function ArticleList({
	contents,
	basePath,
	currentPage,
	totalCount,
	tagId,
}: ArticlelistProps) {
	return (
		<div className="mx-auto rounded-lg bg-white 2lg:p-8 p-8 3xl:px-[4%] xs:px-[20%] md:p-8 lg:px-[8%]">
			<div className="grid 2lg:grid-cols-3 grid-cols-1 gap-8 md:grid-cols-2 xl:grid-cols-3 2xl:grid-cols-4">
				{contents.map((content) => {
					let href = `/${basePath}/article/${content.id}`;

					if (basePath === "category" && "source" in content) {
						href = `/${content.source}/article/${content.id}`;
					} // ここでsourceに応じてinfoかblogのリンクを設定

					return (
						<Link key={content.id} href={href} className="mx-auto block w-fit">
							<Card content={content} />
						</Link>
					);
				})}
			</div>
			<Pagination
				totalCount={totalCount}
				currentPage={currentPage ?? 1}
				basePath={basePath}
				tagId={tagId}
			/>
		</div>
	);
}

さて、これでそれなりにうまくいきました。めでたしめでたし。

オチ

変更日時
2025年6月10日(火) 0時頃

対象プラン
Hobbyプラン

変更内容
APIの上限数を3個から5個に緩和

まとめ

銀の弾丸は生み出せます。そう、神(サービス運営)ならね。

© 2025 SoraPort All Rights Reserved.