ksg

ksg@software_engineer

Portfolio Blog

next-js2026/07/23

Next.js (App Router) + microCMSでブログ機能を実装した話

Next.js (App Router) + microCMSでブログ機能を実装した話

はじめに

ポートフォリオサイトにブログ機能を追加するにあたり、ヘッドレスCMSとして microCMS を採用しました。本記事では、Next.js (App Router) から microCMS を利用する際の実装をコードベースで紹介します。

構成は以下の通りです。

  • microCMS クライアントの初期化と型定義
  • 記事一覧・詳細・カテゴリ別・タグ別ページの取得ロジック
  • キーワード検索、タグ・カテゴリ・最新記事を組み合わせた関連記事のレコメンド
  • ISR (Incremental Static Regeneration) と Webhook による再生成
  • サイトマップ・OGP・構造化データへの応用

環境変数とクライアントの初期化

microCMS への接続情報は環境変数で管理し、未設定の場合はビルドやローカル開発が落ちないように null を返すガード関数にしています。

import { createClient } from "microcms-js-sdk";
import type {
  Blog,
  Category,
  MicroCMSListResponse,
  Tag,
} from "@/lib/microcms.types";

export const BLOG_LIST_LIMIT = 10;

type Client = ReturnType<typeof createClient>;

function getClient(): Client | null {
  const serviceDomain = process.env.MICROCMS_SERVICE_DOMAIN;
  const apiKey = process.env.MICROCMS_API_KEY;

  if (!serviceDomain || !apiKey) {
    return null;
  }

  return createClient({ serviceDomain, apiKey });
}

必要な環境変数は次の3つです。

MICROCMS_SERVICE_DOMAIN=
MICROCMS_API_KEY=
REVALIDATE_SECRET=

getClient()null を返すケースを各関数側で吸収することで、「CMSの認証情報が無い環境でもとりあえずビルドは通る」状態を作っています。関数ごとに try/catch で握りつぶし、失敗時は空配列や空のレスポンスを返すのも同じ考え方です。

:::message alert この「握りつぶして空を返す」設計はトレードオフです。環境変数未設定時にビルドを通しやすくする一方、本番でAPIキーの失効やmicroCMS側の障害が起きても例外が上に伝播せず、画面上は「記事が0件」としか見えません。ログ収集(Sentryなど)や監視と組み合わせないと、障害に気づくのが遅れるリスクがあります。個人開発のポートフォリオ程度ならこの割り切りで十分ですが、業務利用ではエラーを握りつぶす範囲を意図的に絞る(例: ビルド時のフォールバックのみ許容し、実行時は再スローする)判断が必要です。 :::

型定義

microCMS 側のスキーマに合わせて、記事・カテゴリ・タグの型を定義しています。

export type MicroCMSListResponse<T> = {
  contents: T[];
  totalCount: number;
  offset: number;
  limit: number;
};

export type Category = {
  id: string;
  name: string;
  slug: string;
};

export type Tag = {
  id: string;
  name: string;
  slug: string;
};

export type Blog = {
  id: string;
  title: string;
  slug: string;
  content: string;
  excerpt: string;
  eyecatch?: {
    url: string;
    height: number;
    width: number;
  };
  category: Category;
  tags: Tag[] | null;
  publishedAt: string;
  revisedAt: string;
};

microCMS の管理画面では slug をカスタムフィールドとして持たせ、URLに使う識別子として利用しています。category は参照フィールド、tags は複数参照フィールドです。

記事一覧の取得とページネーション

一覧取得は offset / limit によるオフセットページネーションです。

export async function getBlogList(
  page = 1,
): Promise<MicroCMSListResponse<Blog>> {
  const client = getClient();
  if (!client) {
    return emptyListResponse<Blog>();
  }

  const offset = (Math.max(1, page) - 1) * BLOG_LIST_LIMIT;
  try {
    return await client.getList<Blog>({
      endpoint: "blogs",
      queries: { offset, limit: BLOG_LIST_LIMIT, orders: "-publishedAt" },
    });
  } catch {
    return emptyListResponse<Blog>();
  }
}

orders: "-publishedAt" で公開日時の降順ソートを行い、page からクエリ用の offset を計算しています。totalCount はレスポンスに含まれるので、これを使って総ページ数を算出します。

export function getTotalPages(totalCount: number, limit: number): number {
  if (limit <= 0) {
    return 1;
  }

  return Math.max(1, Math.ceil(totalCount / limit));
}

一覧ページ側では次のように呼び出すだけです。

export const revalidate = 3600;

export default async function BlogListPage({
  searchParams,
}: {
  searchParams: Promise<{ page?: string }>;
}) {
  const { page: pageParam } = await searchParams;
  const page = Math.max(1, Number(pageParam ?? "1") || 1);
  const { contents, totalCount } = await getBlogList(page);
  const totalPages = getTotalPages(totalCount, BLOG_LIST_LIMIT);

  // ...PostCard を contents.map で描画、Pagination に currentPage / totalPages を渡す
}

記事詳細の取得と静的生成

詳細ページは slug をキーに microCMS を検索します。microCMS の getListfilters クエリを渡すことで、slug が一致する記事を絞り込んでいます(microCMSの標準APIでは「idではなくslugで直接取得する」エンドポイントが無いため、リストAPIをフィルタして代用する形です)。

export async function getBlogDetail(slug: string): Promise<Blog | null> {
  const client = getClient();
  if (!client) {
    return null;
  }

  try {
    const res = await client.getList<Blog>({
      endpoint: "blogs",
      queries: { filters: `slug[equals]${slug}`, limit: 1 },
    });

    return res.contents[0] ?? null;
  } catch {
    return null;
  }
}

ビルド時に全記事を静的生成するため、generateStaticParams で全 slug を取得します。このとき fields クエリで slug だけを取得することで、レスポンスサイズを抑えています。

export async function getAllBlogSlugs(): Promise<string[]> {
  const client = getClient();
  if (!client) {
    return [];
  }

  try {
    const res = await client.getList<Pick<Blog, "slug">>({
      endpoint: "blogs",
      queries: { fields: "slug", limit: 100 },
    });

    return res.contents.map((blog) => blog.slug);
  } catch {
    return [];
  }
}

詳細ページ側は次のように generateStaticParams / generateMetadata / 本体コンポーネントの3つで構成しています。

export const revalidate = 3600;

export async function generateStaticParams() {
  const slugs = await getAllBlogSlugs();
  return slugs.map((slug) => ({ slug }));
}

export default async function BlogDetailPage({
  params,
}: {
  params: Promise<{ slug: string }>;
}) {
  const { slug } = await params;
  const post = await getBlogDetail(slug);

  if (!post) {
    notFound();
  }

  // ...
}

本文 (post.content) は microCMS のリッチエディタから返ってくるHTML文字列なので、dangerouslySetInnerHTML で描画し、prose クラス (Tailwind Typography) でスタイリングしています。

<div className="prose prose-slate max-w-none p-6 sm:p-8 prose-a:text-sky-700 prose-img:rounded-2xl">
  <div dangerouslySetInnerHTML={{ __html: post.content }} />
</div>

:::message dangerouslySetInnerHTML はXSSの入口になり得るAPIですが、ここで許容しているのは「本文の入力元がmicroCMSの管理画面(=信頼できる編集者のみがログインできる場所)に限定されている」という信頼境界を前提にしているためです。もし将来コメント機能や外部ユーザーからの投稿など、不特定多数が入力できる経路を追加する場合は、同じ実装を流用せず DOMPurify 等でサニタイズする必要があります。 :::

カテゴリ・タグでの絞り込み

カテゴリとタグはそれぞれ独立したAPIエンドポイントとして持ち、slug から取得したい場合は一覧を取ってから find で絞り込む実装にしています。件数が少ないマスタデータなので、専用の検索APIを叩くよりシンプルです。

export async function getCategoryBySlug(
  slug: string,
): Promise<Category | null> {
  const categories = await getCategoryList();
  return categories.find((category) => category.slug === slug) ?? null;
}

カテゴリ・タグに紐づく記事一覧は、参照フィールドのIDを条件にフィルタします。カテゴリは単一参照なので equals、タグは複数参照(配列)なので contains を使う点が違いです。

export async function getBlogsByCategoryId(
  categoryId: string,
  page = 1,
): Promise<MicroCMSListResponse<Blog>> {
  // ...
  return await client.getList<Blog>({
    endpoint: "blogs",
    queries: {
      filters: `category[equals]${categoryId}`,
      offset,
      limit: BLOG_LIST_LIMIT,
      orders: "-publishedAt",
    },
  });
}

export async function getBlogsByTagId(
  tagId: string,
  page = 1,
): Promise<MicroCMSListResponse<Blog>> {
  // ...
  return await client.getList<Blog>({
    endpoint: "blogs",
    queries: {
      filters: `tags[contains]${tagId}`,
      offset,
      limit: BLOG_LIST_LIMIT,
      orders: "-publishedAt",
    },
  });
}

カテゴリページの generateStaticParams でも同様に、全カテゴリの slug を事前に取得してビルド時にページを生成しています。

キーワード検索の実装

検索ページは、microCMSがリストAPIに標準で用意している全文検索クエリ q にそのまま乗せているだけです。専用の検索エンジンを別途用意しなくても、タイトル・本文を横断した検索が実現できます。

export async function searchBlogList(
  query: string,
  page = 1,
): Promise<MicroCMSListResponse<Blog>> {
  const client = getClient();
  if (!client) {
    return emptyListResponse<Blog>();
  }

  const offset = (Math.max(1, page) - 1) * BLOG_LIST_LIMIT;
  try {
    const res = await client.getList<Blog>({
      endpoint: "blogs",
      queries: {
        q: query,
        offset,
        limit: BLOG_LIST_LIMIT,
        orders: "-publishedAt",
      },
    });
    return normalizeBlogList(res);
  } catch {
    return emptyListResponse<Blog>();
  }
}

ページ側は「検索キーワードが空ならAPIを叩かず空表示」「1件もヒットしなければその旨を表示」という分岐だけのシンプルな構成です。一覧・カテゴリ・タグ別ページと同じ PostCard / Pagination コンポーネントをそのまま再利用しているので、検索専用のUIをほぼ書かずに済んでいます。

const { contents, totalCount } = query
  ? await searchBlogList(query, page)
  : { contents: [], totalCount: 0 };

関連記事(おすすめ記事)のレコメンドロジック

記事詳細ページの下部には、関連記事を3件表示しています。おすすめエンジンのような大掛かりな仕組みは使わず、「タグが一致する記事 → それでも足りなければ同じカテゴリの記事 → それでも足りなければ最新記事」という優先順位でソースを積み上げ、重複を除いて必要件数だけ取り出す方式にしました。

export async function getRecommendedBlogs(
  post: Blog,
  count = 3,
): Promise<Blog[]> {
  const client = getClient();
  if (!client) {
    return [];
  }

  try {
    const sources: Blog[][] = [];

    for (const tag of post.tags) {
      const tagRes = await getBlogsByTagId(tag.id);
      sources.push(tagRes.contents);
    }

    if (pickRecommendedBlogs(post.id, count, sources).length < count) {
      const categoryRes = await getBlogsByCategoryId(post.category.id);
      sources.push(categoryRes.contents);
    }

    if (pickRecommendedBlogs(post.id, count, sources).length < count) {
      const latest = await getLatestBlogs(count + BLOG_LIST_LIMIT);
      sources.push(latest);
    }

    return pickRecommendedBlogs(post.id, count, sources);
  } catch {
    return [];
  }
}

重複除去と件数の絞り込みは、微妙にロジックが絡むのでピュア関数として切り出してテストしやすくしています。

export function pickRecommendedBlogs(
  currentId: string,
  count: number,
  sources: Blog[][],
): Blog[] {
  if (count <= 0 || sources.length === 0) {
    return [];
  }

  const seen = new Set<string>([currentId]);
  const result: Blog[] = [];

  for (const source of sources) {
    for (const post of source) {
      if (result.length >= count) break;
      if (seen.has(post.id)) continue;

      seen.add(post.id);
      result.push(post);
    }
    if (result.length >= count) break;
  }

  return result;
}

sources を「優先度の高い順に並んだ配列の配列」として渡し、pickRecommendedBlogs 側は上から順番に見ていって「現在の記事自身」と「既に採用済みの記事」だけを弾くという単純な仕組みです。呼び出し側(getRecommendedBlogs)は都度 pickRecommendedBlogs を呼んで件数が足りているか確認し、足りなければ次のソース(カテゴリ→最新)を追加で取得しています。無関係なAPIリクエストを毎回全部投げるのではなく、タグ一致だけで3件揃うならそこで打ち止めになる、という無駄を減らす作りです。

サイトマップ生成での全件取得

sitemap.ts では、全記事のURLを列挙するために offset をインクリメントしながらページングで全件取得しています。microCMSは1回のリクエストで最大100件までしか返さないため、totalCount を見ながらループする必要があります。

export async function getAllBlogsForSitemap(): Promise<SitemapBlog[]> {
  const client = getClient();
  if (!client) {
    return [];
  }

  const pageSize = 100;
  const all: SitemapBlog[] = [];

  try {
    let offset = 0;
    for (;;) {
      const res = await client.getList<SitemapBlog>({
        endpoint: "blogs",
        queries: {
          fields: "slug,publishedAt,revisedAt",
          limit: pageSize,
          offset,
        },
      });

      all.push(...res.contents);
      offset += pageSize;

      if (offset >= res.totalCount) {
        break;
      }
    }

    return all;
  } catch {
    return all;
  }
}
export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
  const posts = await getAllBlogsForSitemap();

  const postRoutes: MetadataRoute.Sitemap = posts.map((post) => ({
    url: `${SITE_URL}/blog/${post.slug}`,
    lastModified: post.revisedAt,
    changeFrequency: "monthly",
    priority: 0.6,
  }));

  return [...staticRoutes, ...postRoutes];
}

OGP・構造化データへの反映

generateMetadata で microCMS の eyecatch(アイキャッチ画像)や excerpt(抜粋)をそのままOGP・Twitterカードに流用しています。

export async function generateMetadata({
  params,
}: {
  params: Promise<{ slug: string }>;
}): Promise<Metadata> {
  const { slug } = await params;
  const post = await getBlogDetail(slug);

  if (!post) {
    return {};
  }

  const url = `${SITE_URL}/blog/${post.slug}`;
  const images = post.eyecatch
    ? [
        {
          url: post.eyecatch.url,
          width: post.eyecatch.width,
          height: post.eyecatch.height,
          alt: post.title,
        },
      ]
    : undefined;

  return {
    title: post.title,
    description: post.excerpt,
    alternates: { canonical: url },
    openGraph: {
      type: "article",
      title: post.title,
      description: post.excerpt,
      url,
      publishedTime: post.publishedAt,
      modifiedTime: post.revisedAt,
      images,
    },
    twitter: {
      card: "summary_large_image",
      title: post.title,
      description: post.excerpt,
      images: post.eyecatch ? [post.eyecatch.url] : undefined,
    },
  };
}

同様に、BlogPosting の構造化データ (JSON-LD) も microCMS のフィールドから組み立てています。

const jsonLd = {
  "@context": "https://schema.org",
  "@type": "BlogPosting",
  headline: post.title,
  description: post.excerpt,
  image: post.eyecatch ? [post.eyecatch.url] : undefined,
  datePublished: post.publishedAt,
  dateModified: post.revisedAt,
  mainEntityOfPage: `${SITE_URL}/blog/${post.slug}`,
};

eyecatchwidth / height は microCMS が画像アップロード時に自動で返してくれる値で、next/image にそのまま渡すことでレイアウトシフトを防いでいます。

ISRとWebhookによる再生成

各ページには export const revalidate = 3600; を設定し、1時間おきにバックグラウンド再生成される ISR にしています。ただし記事を更新してから最大1時間反映が遅れるのは体験として微妙なので、microCMS のWebhook機能から即時再生成をトリガーするAPI Routeを用意しました。

import { revalidatePath } from "next/cache";
import { NextRequest, NextResponse } from "next/server";

type RevalidateBody = {
  contents?: {
    new?: { slug?: string };
    old?: { slug?: string };
  };
};

export async function POST(request: NextRequest) {
  const secret = request.nextUrl.searchParams.get("secret");
  if (secret !== process.env.REVALIDATE_SECRET) {
    return NextResponse.json({ message: "Invalid secret" }, { status: 401 });
  }

  const body: RevalidateBody = await request.json().catch(() => ({}));
  const slug = body.contents?.new?.slug ?? body.contents?.old?.slug;

  revalidatePath("/");
  revalidatePath("/blog");
  if (slug) {
    revalidatePath(`/blog/${slug}`);
  }

  return NextResponse.json({ revalidated: true, now: Date.now() });
}

microCMS のWebhookは、コンテンツの公開・更新・削除時に contents.new / contents.old を含むペイロードをPOSTしてくれます。ここから更新された記事の slug を取り出し、トップページ・一覧ページ・該当の詳細ページだけをピンポイントで revalidatePath しています。secret クエリパラメータによる簡易認証を入れることで、第三者からの再生成リクエストを弾いています。

microCMS側の管理画面では、Webhook設定でこのAPI RouteのURL(https://<デプロイ先>/api/revalidate?secret=<REVALIDATE_SECRET>)を登録するだけで連携が完了します。

今後の課題: 下書きプレビューは未対応

現状の実装は「公開済みコンテンツの取得」しかカバーしておらず、microCMSの下書きプレビュー機能には対応していません。記事を下書き保存した状態で見た目を確認したい場合、今は管理画面のプレビューに頼るしかなく、Next.js側では確認できません。

microCMSのコンテンツ一覧・詳細取得APIには draftKey というクエリパラメータが用意されており、これを付けてリクエストすると下書き状態のコンテンツを1件取得できます(全下書きの取得には別途権限設定が必要)。対応するなら、getBlogDetaildraftKey を受け取れるオプション引数を追加し、Next.jsの Draft Mode と組み合わせて「プレビューURLを踏んだときだけ下書きを取得する」実装にするのが定石です。今回はスコープ外としたため、次回追加するとしたらこの部分になります。

まとめ

今回の実装のポイントは以下の通りです。

  • クライアント初期化は null 許容にして、環境変数未設定でもビルドが壊れないようにする
  • 一覧・詳細・カテゴリ別・タグ別・検索で同じ Blog 型を使い回し、filters / q クエリで絞り込みを共通化する
  • 関連記事は専用のレコメンドエンジンを使わず、タグ→カテゴリ→最新記事の優先順位でソースを積み上げて重複除去する、という自前ロジックで十分まかなえた
  • generateStaticParams + ISR (revalidate) を基本にしつつ、Webhook経由の revalidatePath で即時反映を補う
  • eyecatchexcerpt などmicroCMSのフィールドをOGP・構造化データにそのまま流用し、SEO対応の手間を減らす
  • エラーを握りつぶす設計や dangerouslySetInnerHTML は、個人開発だから許容できる前提を明記しておく
  • 下書きプレビューなど未対応の機能はスコープ外として明示し、次のタスクにつなげる

microCMSはスキーマ定義さえ済ませてしまえば、Next.jsの App Router や ISR と非常に相性良く組み合わせられました。同じような構成でヘッドレスCMSを検討している方の参考になれば幸いです。

おすすめの記事