Next.js App Router + Vercel + microCMSでブログを作成する

BLOG TITLE
Resonalエンジニアリング部

Resonalエンジニアリング部

2025/10/07

この記事では、Next.jsのApp RouterとヘッドレスCMSの「microCMS」を使って、ブログやメディアを構築する手順を紹介します。App Routerの特徴を意識した開発、そしてVercelとの連携によるwebhookを利用したデプロイまで、実際のプロジェクトで実装した内容をベースに解説していきます。

microCMSの準備

まずヘッドレスCMSであるmicroCMSのアカウント登録をします。以下の公式サイトからアカウントを作成してください。

microCMS|APIベースの日本製ヘッドレスCMS

1. microCMSのアカウント作成とサービス設定

まず、microCMSでアカウントを作成し、新しいサービスを作成します。

サービスは今回は既存のWebサイトに乗せるため「一から作成する」を選択します。

その後サービス情報を入力してください。どちらも後から変更可能です。サービス名には今回会社名を入力しました。

2. APIの作成

次に各APIの作成をしていきます。まずはブログAPIを作成します。API作成画面では雛形が用意されているため今回はブログを選択します。

作成後は、以下の画像のようにブログとカテゴリのAPIが作成され、またサンプルの記事とベースとなるカテゴリが作成されているはずです。

基本的な情報としては十分ですが、今回、タグによる記事の分類や、誰がどの記事を書いたかを読者に見えるようにしたいため、著者APIとタグAPIを続けて作成し、ブログのAPIと連携させます。

再度APIの作成をしていきます。今回はゼロから新しいAPIを作成するため、「自分で作成」を選択します。

その後APIの基本情報を入力する画面では、APIの名前に著者、エンドポイント名には authorsと入力します。

APIの方は内容の通りで、リスト形式を選択します。

APIのスキーマでは、氏名、プロフィール、アイコンのフィールドをそれぞれ設定しました。種類はテキストフィールド、画像などさまざまなものが用意されていますが、必要に応じたものを選択してください。また氏名やアイコンは必須項目としており、入力がなければ作成できない状態にしています。なお、ブログの記事の種類はリッチエディタが選ばれており、こちらについては後述します。

次にブログのAPIに移動して、APIの設定を改めてします。ブログのAPIと著者を紐づけるためです。ブログのAPIを選択して、右上のAPI設定を押下すると以下の画像のページに遷移します。

現在のAPIの定義が確認できます。著者のフィールドを新たに追加するため、「フィールドを追加」を選択します。

空欄のフィールドが作成されます。続けて種類を選択します。

フィールドの種類が選択できるのでコンテンツ参照を選択してください。コンテンツの一覧が表示され、先ほど作成した「著者」を選択します。

フィールドIDと表示名を入力したら、これでブログAPIとの連携は完了です。タグのAPIについては基本的な手順は同じのため割愛します。なお、タグのように記事に対して複数のコンテンツを紐づけたい場合は複数コンテンツ参照をお選びください。

3. APIキーの取得

APIキーは、microCMSの管理画面から「サービス設定」→「APIキー」で確認できます。

APIキーはNext.jsからmicroCMSにコンテンツを取得する際に使用します。また、サービスIDも使用するため、そちらも控えておいてください。

ライブラリのインストールや環境変数の設定


それではNext.jsでの実装を進めていきますが、基本的なReact、TypeScriptの解説やCSSの説明等は割愛して、どうブログを作成するかという点にのみフォーカスします。

1. 必要なパッケージのインストール


microCMSから公式のsdkが用意されているため、今回はそちらを使用します。npmでもpnpmでもお好きなパッケージマネージャーを使用していただいて構いません。また日付の表示のために今回 dayjsを使用します。

npm install microcms-js-sdk dayjs
  • microcms-js-sdk: microCMSの公式SDK
  • dayjs: 日付フォーマット用

2. 環境変数の設定

プロジェクトルートに .envファイルを作成し、以下の環境変数を設定します。先ほど用意したAPIキーとmicroCMSの自分のドメインを入力します。こちらは後ほどVercelの環境変数にも指定する必要があります。 NEXT_PUBLIC_URLはsitemapの作成の際に使用します。

MICROCMS_SERVICE_DOMAIN=your-service-name
MICROCMS_API_KEY=your-api-key
NEXT_PUBLIC_URL=https://your-domain.com

3. microCMSクライアントの作成

続けて、microCMSのクライアントを使用するためのファイルを作成します。APIキーとmicroCMSのドメインの環境変数をクライアントに渡して使用可能な状態にします。

import { createClient } from 'microcms-js-sdk';

if (!process.env.MICROCMS_SERVICE_DOMAIN || !process.env.MICROCMS_API_KEY) {
  throw new Error('MICROCMS_SERVICE_DOMAIN or MICROCMS_API_KEY is not set');
}

export const client = createClient({
  serviceDomain: process.env.MICROCMS_SERVICE_DOMAIN,
  apiKey: process.env.MICROCMS_API_KEY
});

Next.jsでの一覧 / 詳細ページの作成

app/blog/page.tsxを作成します。まずはmicroCMSからブログの一覧を取得するための関数を作成します。

import { client } from '../../lib/microcms';

async function getPosts(page: number) {
  const _posts = await client.get<PostList>({
    endpoint: 'blogs',
    queries: {
        fields: ['id', 'title', 'publishedAt', 'author', 'eyecatch', 'category', 'content'],
        limit: 10,
        offset: (page - 1) * 10,
    },
  });

  const posts = {
    ..._posts,
    contents: _posts.contents.map((post) => {
      return {
        ...post,
        content: content: post.content.replace(/<[^>]*>/g, '').slice(0, 120) + '...',
      }
    }),
  }
  
  return posts;
}

先ほど作成したライブラリを使用してclient.get()でブログの一覧を取得します。そのためendpointは blogsを指定してください。queriesのフィールドでは一覧画面で使用するフィールドの指定ができます。必要となるフィールドを指定してください。offsetとlimitについては割愛します。

なお、型情報である PostList は自身で定義しています。フィールドの情報はユーザーによって異なるため、自分で定義する必要があり、デフォルトはanyになっております。

余談ですが、postsを返す際に、contentsからHTMLのタグを取り除いた上でsliceして文字数を減らしています。これは抜粋を簡略化して表示するためです。しかし、基本的には抜粋用のフィールドを用意するのがいいかと思われます。

一覧コンポーネントの作成

取得したブログの一覧を表示するためのコンポーネントを作成していきます。実際に使用しているものとほぼ同様のものを記載しています。基本的には受け取ったListのレスポンスを展開していくだけです。

以下のコンポーネントでは、(1)ページネーション処理と一覧の表示、(2)記事カードの表示、の2つを実装しています

type SearchParams = {
  page: string;
}

export const revalidate = 3600; // 1時間ごとにキャッシュ無効化

export default async function Blog({ searchParams }: {
  searchParams: Promise<SearchParams>
}) {
    const params =  await searchParams
    const page = params.page ? parseInt(params.page) : 1;
    const posts = await getPosts(page);
    const totalPages = Math.ceil(posts.totalCount / 10);

    return (
        <main>
           <PageHeader 
            subtitle="BLOG"
            heading="ブログ"
            breadcrumbs={<TopicPath title="ブログ記事" />}
          />
          <ul className="mx-auto grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-8">
            {posts.contents.map((post) => (
                <BlogCard key={post.id} post={post} />
              ))}
            </ul>
          </div>
          {posts.contents.length > 0 && (
          <Pagination>
            <PaginationContent>
              {page > 1 && (
                <PaginationItem>
                  <PaginationPrevious href={`/blog?page=${page - 1}`} />
              </PaginationItem>
              )}
              <PaginationItem>
                <PaginationLink href={`/blog?page=${page}`}>
                  {page}  
                </PaginationLink>
              </PaginationItem>
              {page < totalPages && (
                <PaginationItem>
                  <PaginationNext href={`/blog?page=${page + 1}`} />
                </PaginationItem>
              )}
            </PaginationContent>
          </Pagination>
          )}
        </main>
      );
}

const BlogCard = ({ post }: { post: Post }) => {
  return (
    <li className="bg-white rounded-lg shadow-md overflow-hidden">
      <section>
        <Link href={`/blog/${post.id}`}>
          <img src={post.eyecatch.url} alt={post.title} className="w-full object-cover mb-8 block" />
          <div className="px-6 pb-8">
            <p className="text-primary font-bold mb-6 text-sm md:text-base">{post.category.name}</p>
            <h2 className="text-base md:text-xl font-bold mb-8">{post.title}</h2>
            <p className="text-sm md:text-base leading-relaxed" >{post.content}</p>
          </div>
          <div className="px-6 py-6 border-t border-gray-200 flex justify-between text-xs md:text-sm">
            <p className=" text-gray-500 flex items-center">
              <span className="inline-block align-middle mr-2" aria-label="calendar" role="img">
                <Calendar className="h-4 w-4 text-gray-400" />
              </span>
              {dayjs(post.publishedAt).format('YYYY/MM/DD')}
            </p> 
            <p className=" text-gray-500 flex items-center"> 
              <span className="inline-block align-middle mr-2" aria-label="calendar" role="img">
                <User className="h-4 w-4 text-gray-400" />
              </span>
              {post.author.name}
            </p>
          </div>
        </Link>
      </section>
    </li>
  );
};

export const revalidate = 3600; revalidateの設定を加えることによって、指定した秒数(ここでは1時間)ごとにページのキャッシュが自動的に無効化され、次回アクセス時に最新のコンテンツで再生成されます。

こちらで一覧のコンポーネントの作成は完了です。基本的には難しいところはなく、前述のとおりレスポンスの内容を展開していくだけです。 また、dayjsによる日付のフォーマットは dayjs(post.publishedAt).format('YYYY/MM/DD') で行なっています。

アイキャッチの画像などを指定してしていない場合などは別の画像が表示されるようにしたりしておくといいかもしれません。また今回Next.jsで作成してるため記事への詳細へのリンクは、 Linkコンポーネントを使用しています。

記事詳細コンポーネントの作成

それでは一覧から飛んだ先の記事の詳細のページを作成していきます。記事IDに応じてページが作られるため、Dynamic Routesにて作成するため、 blogs/[id]/page.tsxのような構造になります。

まず同様に記事取得のための関数を作成します。

async function getPost(id: string) {
  const post = await client.get<Post>({
    endpoint: 'blogs',
    contentId: id,
  });
  return post;
}

特に変わったところはないはずです。一覧と違うのは、contentIdに記事のidを指定している点です。

記事の詳細のコンポーネントを作成していきます。Dynamic RoutesにてページのURLが blog/{id}の形になるため、paramsからidを受け取ります。受け取ったidを先ほど作成した関数の引数として渡してください

type Props = {
  params: Promise<{ id: string }>;
};

export default async function Blog({ params }: Props) {
  const id = (await params).id;
  const post = await getPost(id);

  return (
    <BlogPost post={post} />
  );
}
export default function BlogPost({ post }: { post: Post }) {
  return (
    <main>
    <article>
    <PageHeader 
        subtitle="BLOG TITLE"
        heading={post.title}
        breadcrumbs={<TopicPath title={["ブログ記事", post.title]} />}
        />
    <div dangerouslySetInnerHTML={{__html: post.content}} className="container py-16 post lg:max-w-4xl mx-auto" />
    </article>
  </main>
  );
}

今回、 BlogPostのコンポーネントは直接page.tsxに記載せず、コンポーネントとして切り出しています。これはpreviewページにて再利用するためです。また一覧ページでは post.content からHTMLのタグを除いた上で出力しましたが、今回はタグを含んだリッチコンテンツであるため、 dangerouslySetInnerHTMLにて表示します。dangerouslySetInnerHTMLはHTMLに悪意あるスクリプトが含まれているとXSS(クロスサイトスクリプティング)攻撃のリスクがありますが、今回はmicroCMSから取得しているデータであり、信頼できる管理者だけが編集できるもののため使用しています。

export async function generateStaticParams() {
    const contentIds = await client.getAllContentIds({ endpoint: 'blogs' });
  
    return contentIds.map((contentId) => ({
      id: contentId,
    }));
  }

忘れずに記載してもらいたいのが、 generateStaticParamsです。 generateStaticParamsはDynamic Routesをビルド時に静的生成するために使います。Dynamic Routes(動的URL)ではどのページを事前生成すればいいかNext.jsが判断できません。そのためgenerateStaticParamsを指定して、Dynamic Routesをビルド時に静的生成するようにします。指定してない場合、オンデマンドでレンダリングされるため、ユーザーが初めてアクセスしたときにサーバー側でページが生成されます。そのため初回アクセスのページ表示が比較的遅くなる可能性が高いです。

Vercelでの環境設定

基本的なプロジェクトの設定は割愛させていただきます。弊社ではGitHubにてソースコードの管理をしているので、GitHubとVercelを連携し、該当のリポジトリをインポートするだけです。

インポートするだけだと、開発時に指定した、環境変数が設定されていないため、おそらくビルドのエラーが発生します。忘れずにVercelの環境変数の設定画面にて該当の環境変数を指定してください。

これでmainブランチに変更が加わると、ビルド&デプロイが実行されるはずです。

Deploy Hooksの作成

microCMSにて記事を公開する際、そのままだと記事は反映されません。再ビルド・デプロイが実行されないためです。これを解決するためにDeploy Hooksを設定します。microCMS上でWebhookの設定をして、記事の公開等のタイミングなどでVercelがDeploy Hooksを使ってサイト全体を再デプロイできるようにします。settings -> Git -> Deploy Hooksの設定をして、URLをコピーしておいてください。

Webhookの設定

これで基本的な設定は完了していますが、このままだと記事を公開したとしても手動でデプロイしないとアプリケーション上に反映されません。それでは非ソフトウェアエンジニアのような方々が運用していくには不都合が多いため、microCMS上で記事を公開したらサイトも更新されるようにします。

ブログのAPIを選択して、APIの設定に移動します。Webhookの項目があるためそちらを選択してください。

サービスがいくつか選択できますが、今回はVercelを選択します。

設定画面が表示されるので、先ほどVercelの設定の際にコピーしたURLを入力してください。なおWebhookの名前はただの識別用のため、お好きな名前で問題ありません。

通知タイミングもさまざまなタイミングがありますが、弊社は公開がされたとき、公開が終了したとき、記事が削除されたときを中心に選択しました。

これでWebhookの設定は完了で、記事を更新や作成したタイミングで、Webhookによる通知が発生し、Vercel上で自動で記事を公開するための再ビルドとデプロイメントを行うはずです。

プレビュー機能の作成

これまでの設定で基本的にはブログとして運用していけますが、最後にプレビュー機能の実装をします。microCMS上では画面プレビューの機能がありますが、現時点では特に設定をしていないため、記事の下書き中に実際の画面を通して事前に確認することができません。ブログのAPI設定から画面プレビューを選択してください。

プレビューとなる予定のページの設定をします。URLの中に {CONTENT_ID}{DRAFT_KEY}を含める必要があります。ドラフトキーをサーチパラメーターにする理由は、パスとクエリの役割分担をするためです。/blog/{CONTENT_ID}はコンテンツという「リソース」を識別するのに対し、draftKeyはあくまで「下書き版を見るための一時的な認証情報」なので、パス(恒久的なリソース識別子)ではなくクエリパラメーター(状態やオプション)として扱うのが適切です。

Previewコンポーネントの作成

blog/[id]/preview/page.tsxを作成します。ブログで作った、 BlogPostのコンポーネントをそのまま流用します。

import BlogPost from "@/components/page/BlogPost";
import { Post } from "../../types";
import { client } from "@/lib/microcms";

export const dynamic = "force-dynamic";

export const metadata = {
  robots: "noindex",
};

async function getPost(id: string, draftKey: string) {
  const post = await client.get<Post>({
    endpoint: 'blogs',
    contentId: id,
    queries: { draftKey: draftKey },
  });
  return post;
}

export default async function Preview({ params, searchParams }: { params: Promise<{ id: string }>, searchParams: Promise<{ draftKey: string }> }) {
  const id = (await params).id;
  const draftKey = (await searchParams).draftKey;
  const post = await getPost(id, draftKey);

  return (
    <BlogPost post={post} />
  );
}

export const dynamic = "force-dynamic"を指定している理由は、プレビューは毎回最新の下書きを取得する必要があるため、静的生成やキャッシュを無効化する必要があります。指定をしなくても静的生成はされませんが、明示的に動的に生成する必要があるということを示すため記述を加えています。

また、プレビュー画面のため検索エンジンにはインデックスさせたくないため、忘れずに noindexの指定をしてください。

あとは searchParamsとして draftKey を受け取るようにして、受け取ったドラフトキーをクエリパラメータとして送信してあげれば設定は完了です。

これでプレビューの設定は完了です。下書き中は、下書き中と画面に明示したいなどの要望がある場合はドラフトキーも有無やパスの違いを条件に、「これは下書き中の記事です」など表示させてあげればよいかと思います。

なお、弊社ではまだ対策しておりませんが、間違ったドラフトキーを送信してもそのドラフトキー自体の検証はされません。そのためエラーなどにはならず公開中の記事が表示されたり、記事が未公開の場合には404となります。

以下の記事にて詳しく説明がされておりますので、気になる方はご一読ください。

microCMSで下書きをプレビューする際にdraftKeyを検証する

robots.txtとsitemapの設定

最後に検索エンジン向けにrobotsとsitemapの設定をNext.js上で行います。これらはSEO(検索エンジン最適化)のためで、検索エンジンに対してサイトの構造やクローリングルールを伝える役割があります。

robots.tsの作成

プレビュー画面は検索エンジンの評価から外したいので、robots.txtにプレビュー画面を評価から外す旨を記載します。 app/robots.ts を作成してください。

import { MetadataRoute } from 'next'

export default function robots(): MetadataRoute.Robots {
  return {
    rules: {
      userAgent: '*',
      allow: '/',
      disallow: [
        '/*/preview',        // すべてのpreviewページ
        '/*/preview?*',      // クエリパラメータ付きのpreview
      ],
    },
    sitemap: `${process.env.NEXT_PUBLIC_URL}/sitemap.xml`,
  }
}
  • userAgent: '*': すべての検索エンジンクローラーに対して適用
  • allow: '/': サイト全体へのアクセスを許可
  • disallow: プレビューページは検索結果に表示させたくないため、クロールを禁止
  • sitemap: sitemap.xmlの場所を明示し、検索エンジンがサイト構造を効率的に把握できるようにする

sitemap.tsの作成

sitemap.xmlは、サイト内の全ページのURL一覧を検索エンジンに伝えるためファイルです。これにより、新規記事や更新された記事を検索エンジンに素早く通知できます。 app/sitemap.ts を作成してください。

import { client } from '@/lib/microcms'


export default async function sitemap() {
  const baseUrl = process.env.NEXT_PUBLIC_URL!
  
  const { contents: blogs } = await client.getList({ endpoint: 'blogs' })
  
  return [
    { url: baseUrl, lastModified: new Date(), priority: 1 },
    { url: `${baseUrl}/blog`, lastModified: new Date(), priority: 0.9 },
    
    
    ...blogs.map((item: any) => ({
      url: `${baseUrl}/blog/${item.id}`,
      lastModified: new Date(item.revisedAt),
      priority: 0.8,
    })),
  ]
}
  • microCMSから全ブログ記事を取得
  • トップページ(priority: 1)、ブログ一覧(priority: 0.9)、各記事(priority: 0.8)の順で優先度を設定
  • lastModified: 各ページの最終更新日を設定。記事はrevisedAt(更新日時)を使用
  • 記事が追加・更新されるたびに、自動的にsitemap.xmlに反映される

この設定により、検索エンジンがサイト構造を正確に理解し、新規記事や更新を迅速にインデックスできるようになります。補足で revisedAtを使用している理由は、実際に公開された最終更新日時なので、検索エンジンに正確な情報を伝えられるためです。

まとめ

以上が基本的なブログとして公開する手順でした。パフォーマンスやコンテンツ運用の利便性を考えて、キャッシュの無効化のために revalidatePathを使うなど、やれることはまだありますが、今回の内容としては以上とします。

Next.jsのApp RouterとヘッドレスCMSのmicroCMSを組み合わせることで、高速で保守性の高いブログ・メディアサイトを構築できます。Next.jsのApp Routerは、静的生成の高速性を保ちながら、必要に応じて動的な機能も利用できる点が大きな強みです。Server Componentsによる効率的なデータ取得や、generateStaticParamsによる静的生成、revalidateによる定期更新を組み合わせることでパフォーマンスとコンテンツの鮮度を両立したサイト構築が可能です。

またmicroCMSは今回初めて使用しましたが、個人的にはUIがシンプルで開発者にとっても記事を書く人にとっても使いやすいHeadless CMSだと思います。Headless CMSは海外製のものが多く、日本語対応されてないものがほとんどのため、microCMSの採用は理にかなってると感じました。

従来のサイト構築ではWordPressが定番でしたが、高速表示やモダンな開発体験、セキュリティ面での優位性から、Next.js + microCMSのような構成が新しい選択肢として注目されています。 プロジェクトの要件に応じて、こうした技術スタックを検討してみる価値は十分にあるでしょう。