microCMS + Next.js でJamStackブログを作ってみた

8 min read読了の目安(約7900字

はじめに

microCMSとは

microCMSはヘッドレスCMSを提供するサービスです。
microCMSのようなヘッドレスCMSを使うことで、コンテンツの編集や管理などを行うバックエンドと、バックエンドから取得したコンテンツを表示するフロントエンドを分離することができます。
対して、WordPressなどの従来のCMSはコンテンツの編集や管理、表示など全てを管理します。

JAMStackとは

JavaScript API Markup の頭文字を取ったもので、JavaScriptとAPIとMarkupを組み合わせたWebアプリケーションアーキテクチャです。

  • JavaScriptはWebページの中でのユーザーの状態(State)や動的に変化する要素について処理します。
  • APIはJavaScriptからバックエンドのAPI(microCMS)を呼び出し、データの参照などをおこないます。
  • MarkupはWEBページで配信される静的なHTMLのことを指します。

Next.jsは状態管理、APIの呼び出し、静的なHTMLの生成全てをカバーします。

Next.jsとmicroCMSで個人ブログを作る

前提

microCMSのブログ記事を参考に説明していきます。ブログがわかりやすいので、詳しくはそちらを参考にしてもらうとして(雑ですが...)、気になったところだけ抜粋していきます。

準備

ブログ記事の手順1〜3を参考に、Next.jsとmicroCMSの準備をします。
microCMSには個人向けのHobbyプランがありカード登録等無く無料で開始することができます。
データ転送量によってHobbyプランでも料金が発生することがあるとのことですが、普通に使っていて料金が発生することはまずないと思います。

ブログ一覧の表示

ブログ記事の手順4を参照してください。
ここで注目するのはgetStaticProps(公式の説明)です。これはビルド時にサーバ側で呼ばれる関数で、ビルド時にmicroCMSからデータを取得し、そのデータを使って静的なHTMLを作成します。JAMのM(arkup)の部分をここで行います。
ブログ一覧の場合はブログ一覧APIを叩き、必要なタイトルや作成日時などを使って一覧HTMLを作成していきます。

ブログ詳細の表示

ブログ記事の手順5を参照してください。
ここでも表示するブログの詳細を取得するためにブログ詳細APIを叩くので、getStaticPropsを使用しています。
この手順で新たに出てくるのがgetStaticPaths(公式の説明)というものです。getStaticPathsは静的なパスを生成します。Next.jsは何もしないとブログのIDを知るすべがないので、どういったパスが叩かれるかを知りません。そこでgetStaticPathsを使用することで、あらかじめどういったパスが叩かれるかをNext.jsに教えてパスを生成しておいてもらいます。
ここまでの手順で装飾は一切ありませんが、一覧と詳細の最低限のブログが完成しました。

Vercelにデプロイする

ブログ の手順8,9の通りに実施してください。
過去にお名前.comで取得した使っていないドメインがあったので、ついでにこちらを参考にドメインの設定も行いました。
特に詰まることもなく、1日もかからずデプロイ・ドメインの設定までできました!

コードを改善していく

ブログの通りに実装しただけだと、API通信で同じようなコードをたくさん書くことになるので、ここら辺をリファクタしていきます。
API通信はfetchを使うのではなく、kyを使うことにしました(kyは型の部分でNext.jsとの親和性がまだ微妙なところがありそうなので注意)。以下のように実装することでconst response = await api.get(`blog/${id}`);という風にX_API_KEYAPI_ENDPOINTを毎回指定しなくて済むようになります。

import ky from 'ky';

export const api = ky.create({
  prefixUrl: process.env.API_ENDPOINT,
  hooks: {
    beforeRequest: [
      (request) => {
        request.headers.set('X-Requested-With', 'ky');
        request.headers.set('X-API-KEY', process.env.API_KEY as string);
      },
    ],
  },
});

こんな風に気になるところをどんどん改善していくことでいい勉強になりそうです。
私はまだ使っていませんが、つい最近SDKが出たようなのでそちらを試すのもいいかもしれません。
APIリクエスト周りはこちらに私のコードをまとめています。

機能を追加してみる

試しに機能を追加してみましょう。ブログでまずあった方がいい機能としてページネーションがあります。これもmicroCMSのブログ記事になっていましたが、こちらはページ番号によるページネーションなので、Previous, Nextのような単純なページネーションを実装します。

早速ページネーション部分の実装です。
現在のページ番号(currentPageNumber)と最大ページ(maxPageNumber)を受け取り、
現在のページ番号が1だったらPrevは表示せず、現在のページ番号が最大ページだった場合はNextを表示しません。PrevとNextのページ番号は現在ページ番号の-1+1です。

import React from 'react';
import Link from 'next/link';

type P = {
  maxPageNumber: number;
  currentPageNumber: number;
};

export const PaginationArrow: React.FC<P> = ({
  maxPageNumber,
  currentPageNumber,
}) => {
  const prevPage = currentPageNumber - 1;
  const nextPage = currentPageNumber + 1;

  return (
    <div>
      {currentPageNumber !== 1 && (
        <Link href={`/blog/page/${prevPage}`}>
          <a data-testid="previous">&lt; Previous</a>
        </Link>
      )}
      {currentPageNumber !== maxPageNumber && (
        <Link href={`/blog/page/${nextPage}`}>
          <a data-testid="next">
            Next &gt;
          </a>
        </Link>
      )}
    </div>
  );
};

次はこちらのコンポーネントをReact Testing Libraryでテストします。Next.jsは設定なしで、React Testing Libraryを使い始められるので非常に便利です。(追記:設定は必要でした)

import React from 'react';
import { render, screen } from '@testing-library/react';
import { PaginationArrow } from './pagination-arrow';

describe('PaginationArrow', () => {
  it('最大ページ数が1で現在ページが1の場合ページ送りが表示されないこと', () => {
    render(<PaginationArrow maxPageNumber={1} currentPageNumber={1} />);

    expect(screen.queryByTestId('previous')).toBeFalsy();
    expect(screen.queryByTestId('next')).toBeFalsy();
  });

  it('最大ページ数が2で現在ページが1の場合次ページへのリンクが表示されること', () => {
    render(<PaginationArrow maxPageNumber={2} currentPageNumber={1} />);

    expect(screen.queryByTestId('previous')).toBeFalsy();
    expect(screen.getByTestId('next').closest('a')).toHaveAttribute(
      'href',
      '/blog/page/2',
    );
  });

  it('最大ページ数が3で現在ページが2の場合前・次ページへのリンクが表示されること', () => {
    render(<PaginationArrow maxPageNumber={3} currentPageNumber={2} />);

    expect(screen.getByTestId('previous').closest('a')).toHaveAttribute(
      'href',
      '/blog/page/1',
    );
    expect(screen.getByTestId('next').closest('a')).toHaveAttribute(
      'href',
      '/blog/page/3',
    );
  });

  it('最大ページ数が3で現在ページが3の場合前ページへのリンクが表示され、次ページのリンクがないこと', () => {
    render(<PaginationArrow maxPageNumber={3} currentPageNumber={3} />);

    expect(screen.getByTestId('previous').closest('a')).toHaveAttribute(
      'href',
      '/blog/page/2',
    );
    expect(screen.queryByTestId('next')).toBeFalsy();
  });
});

上記テストコードで以下のテストをしています。

  • 最大ページ数が1で現在ページが1の場合ページ送りが表示されないこと
  • 最大ページ数が2で現在ページが1の場合次ページへのリンクが表示されること
  • 最大ページ数が3で現在ページが2の場合前・次ページへのリンクが表示されること
  • 最大ページ数が3で現在ページが3の場合前ページへのリンクが表示され、次ページのリンクがないこと

Prev, Nextが表示されているかよりはhrefに正しいリンクが設定されているかをテストしています。
React Testing Libraryの記事ではないので詳しくは説明しませんが、ロジックが入るコンポーネントや複雑なことをしているコンポーネントは別ページに切り出してテストをするとバグが少なくなるかと思います。

次に一覧ページにページネーションを組み込みます。

import React from 'react';
import { NextPage, GetStaticProps } from 'next';
import Head from 'next/head';

import { SiteHeader } from 'components/site-header';
import { Footer } from 'components/footer';
import { PER_PAGE, siteName } from 'index';
import { getBlogList } from 'domains/microCMS/services/get-blog-list';
import { BlogList } from 'components/blog/blog-list';
import { PaginationArrow } from 'components/pagination/pagination-arrow';
import { BlogResponse } from 'domains/microCMS/models/blog';

type P = {
  blogs: BlogResponse[];
  totalCount: number;
};

const Index: NextPage<P> = ({ blogs, totalCount }) => {
  return (
    <div className="wrapper">
      <Head>
        <title>{siteName}</title>
      </Head>
      <SiteHeader />
      <div className="main-wrapper">
        <BlogList blogs={blogs} />
        <PaginationArrow
          currentPageNumber={1}
          maxPageNumber={Math.ceil(totalCount / PER_PAGE)}
        />
      </div>
      <Footer />
    </div>
  );
};

export const getStaticProps: GetStaticProps = async () => {
  const data = await getBlogList();

  return {
    props: {
      blogs: data.contents,
      totalCount: data.totalCount,
    },
  };
};

export default Index;

一覧は最初のページなのでcurrentPageNumberを1固定で入れています。また、1ページに何件ブログを表示するかをPER_PAGEに設定しMath.ceil(totalCount / PER_PAGE)で最大ページ数を算出します。ブログ一覧部分を別コンポーネントに切り出すことで、コードをできるだけスッキリさせます。

このままだと/blog/page/{number}のパスが存在しないことになるので、getStaticPathsでページのパスを生成する必要があります。

const range = (start: number, end: number): number[] =>
  Array.from({ length: end - start + 1 }, (_, i) => start + i);

export const getStaticPaths = async (): Promise<{
  paths: string[];
  fallback: boolean;
}> => {
  const data = await getBlogList();
  const { totalCount } = data;
  const paths = range(1, Math.ceil(totalCount / PER_PAGE)).map(
    (i) => `/blog/page/${i}`,
  );

  return { paths, fallback: false };
};

こんな感じでテストを書きながら、どんどん機能を追加していけそうです!

最後に

今回はじめてmicroCMSを使ってみましたが、驚くほど簡単にブログを公開することができました。microCMSのブログ記事が詳しく書かれているのもとても助かりました。
これから機能や装飾をもっと充実させて、自分好みのブログを作っていきたいと思います。

以下に私が書いたソースコードを公開しています。

https://github.com/haro-rokki/blog-with-micro-cms