[Next.js+Vercel+microCMS] microCMS と Next.js でブログを作る

microCMS と Next.js で作ったブログを Vercel にデプロイして ISG を試してます。
2021.02.22

microCMS がとても気になっていたので、シンプルなブログを作ってみることにしました。

完成品は上記のリポジトリにあります。

今回作るブログについて

  • API は microCMS を使う
  • ホームではブログの一覧を表示する
  • ブログの記事はタグを持っている
    • 記事についているタグのリンクはタグ一覧のリストに遷移する
  • HTTP クライアントラッパーの aspida を使う
  • 下書きの preview モードを実装する
  • Next.js で ISG

ISG にすることで Webhook を使用しなくても、microCMS で記事を公開状態にするだけでサイト側でも更新されます。Vercel にデプロイするのであれば特に設定が必要になることもありません。

記事の更新ごとに毎回ビルドされることもないことを考えると SSG よりも気軽に記事の更新ができそうです。

microCMS で API を作成する

さっそく microCMS で API を作成していきましょう。アカウントやサービスの作成は公式ドキュメントに詳しくありますので、そちらを御覧ください。日本のサービスのため、ドキュメントがわかりやすい日本語で嬉しいですね。

今回作る API は次のとおりです。

/sitedata - サイトデータ

サイト全体のデータを登録しておくオブジェクト形式の API です。今回はサイト名のみを登録します。

  • エンドポイント /sitedata
フィールド ID 表示名 種類 その他
title ブログタイトル テキストフィールド 必須項目

オブジェクト形式の API はこのような設定系のデータに使用できます。

/tags - タグ

タグを登録しておくリスト形式の API です。

  • エンドポイント /tags
フィールド ID 表示名 種類 その他
name タグ名 テキストフィールド 必須項目/ユニーク

ブログに紐付けられるタグのリストです。種類がテキストフィールドの場合、他の種類で設定できる項目の他にいくつかの設定が追加で設定できます。

ここではタグ名が重複しないため、ユニークになるように設定しています。テキストフィールドにはユニーク設定をすることができます。

リスト形式の API はこのような複数個存在するデータに使用できます。

/blogs - ブログ

ブログコンテンツを登録しておくリスト形式の API です。

  • エンドポイント /blogs
フィールド ID 表示名 種類 その他
title タイトル テキストフィールド 必須項目
body 本文 リッチエディタ 必須項目
tags タグ 複数コンテンツ参照 - タグ

ブログのコンテンツになります。

リッチエディタを使用すると、API から HTML が戻されます。ただ改行がそのまま <br> で出力されたり、画像にひと手間加えたくなるケースはありそうなので、こだわりがあるのであればテキストエリアを選択する必要がありそうです。今回はこだわらないのでリッチエディタを選択しています。

また、記事から登録済みのタグを登録できるようにしています。一記事に複数個の登録可能なタグなので、複数コンテンツ参照から参照コンテンツとしてタグを選択しました。

slug のようなフィールドはエンドポイントとなるコンテンツ ID で持つことになるため、フィールド ID として別途用意する必要はりません。

Next.js でブログを作っていく

Next.js については 公式サイト と、公式のリポジトリに含まれている examples が非常に参考になります。こちらでもざっくりと導入までの手順を紹介していきますが、できるだけ公式サイトを参考にしていただくことを推奨します。

インストール

yarn create next-app

上記でインストールします。What is your project named? と質問されるのでプロジェクト名を入力します。次に TypeScript を導入していきます。

touch tsconfig.json && yarn dev

上記コマンドを叩くと、叩くコマンドが指示されるので指示されたとおりのコマンドを叩きます。

yarn add --dev typescript @types/react @types/node

自動で生成される tsconfig.json"strict": false となっているため、"strict": true に書き換えることを忘れないようにしましょう。

ESLint / Prettier

ESLint と Prettier を導入していきます。

yarn add -D eslint @typescript-eslint/eslint-plugin @typescript-eslint/parser eslint-config-prettier eslint-plugin-import eslint-plugin-react eslint-plugin-react-hooks

あわせて以下のファイルを追加しました。

.editorconfig
root = true

[*]
charset = utf-8
indent_style = space
indent_size = 2
insert_final_newline = true
trim_trailing_whitespace = true

[*.md]
max_line_length = off
trim_trailing_whitespace = false
.eslintrc.js
module.exports = {
  extends: ["eslint:recommended"],
  env: {
    browser: true,
    node: true,
    es6: true,
  },
  parserOptions: {
    ecmaVersion: 2020,
  },
  overrides: [
    {
      files: ["**/*.{ts,tsx}"],
      extends: [
        "eslint:recommended",
        "plugin:react/recommended",
        "plugin:@typescript-eslint/recommended",
        "prettier/@typescript-eslint",
      ],
      parser: "@typescript-eslint/parser",
      settings: {
        react: {
          pragma: "React",
          version: "detect",
        },
      },
      parserOptions: {
        sourceType: "module",
        project: "./tsconfig.json",
        ecmaFeatures: {
          jsx: true,
        },
      },
      plugins: ["@typescript-eslint", "react", "react-hooks", "import"],
      rules: {
        "no-unused-vars": "off",
        "import/order": [
          "error",
          {
            groups: ["builtin", "external", "internal"],
            pathGroups: [
              {
                pattern: "react",
                group: "external",
                position: "before",
              },
            ],
            pathGroupsExcludedImportTypes: ["react"],
            "newlines-between": "always",
            alphabetize: {
              order: "asc",
              caseInsensitive: true,
            },
          },
        ],
        "@typescript-eslint/no-unused-vars": "error",
        "react/jsx-no-target-blank": "error",
        "react/prop-types": "off",
        "react-hooks/rules-of-hooks": "error",
        "react-hooks/exhaustive-deps": "error",
      },
    },
  ],
  globals: {
    React: "writable",
  },
};
.prettierignore
node_modules
.next
yarn.lock
public
src/api/$api.ts

あまりデフォルトの設定を変更することが好きではないので、prettier の設定は追加していません。

Jest

今回はサンプルのサイトのためあまりテストを書くことがありませんが、Jest を導入しておきます。

yarn add -D @types/jest babel-jest jest
.babelrc
{
  "presets": ["next/babel"]
}
jest.config.js
module.exports = {
  roots: ["<rootDir>"],
  moduleFileExtensions: ["ts", "tsx", "js", "json", "jsx"],
  testPathIgnorePatterns: ["<rootDir>[/\\\\](node_modules|.next)[/\\\\]"],
  transform: {
    "^.+\\.(ts|tsx)$": "babel-jest",
  },
  transformIgnorePatterns: ["[/\\\\]node_modules[/\\\\].+\\.(ts|tsx)$"],
};

package.json には以下のコードを追加しておきます。

{
  "scripts": {
    "lint": "eslint 'src/**/*.{ts,tsx}' && prettier --check '**/*.{js,ts,tsx,json}'",
    "lintfix": "eslint --fix 'src/**/*.{ts,tsx}' && prettier --write '**/*.{js,ts,tsx,json}'",
    "typecheck": "tsc --pretty --noEmit",
    "test": "jest --watch",
    "test:ci": "jest --ci"
  }
}

以上で環境まわりのインストール作業は完了となります。

aspida を使って API にアクセスする

API のリクエストを型安全にするため、aspida を使用します。

yarn add @aspida/fetch

Next9.4 以降ではすべての環境での fetch が提供されており、 polyfill が必要なくなったため、インストールは @aspida/fetch で問題ありません。

まずは API のレスポンスとクエリに使用する型を書いていきます。

microCMS では API スキーマのエクスポートもできるのですが、こちらのファイルがどういった形式なのかわからず、型を自動生成できませんでした。そのためここでは API の型を手動で記述していきます。

// src/types/api.ts
// リスト形式のレスポンス用
export type ListContentsResponse<T> = {
  contents: T[];
  totalCount: number;
  offset: number;
  limit: number;
};

// オブジェクト形式のレスポンス用
export type ContentResponse<T> = {
  id: string;
  createdAt: string;
  updatedAt: string;
  publishedAt: string;
  revisedAt: string;
} & T;

// リスト形式のクエリ用
// https://document.microcms.io/content-api/get-list-contents
export type GetListContentsQuery = {
  draftKey?: string;
  limit?: number;
  orders?: string;
  q?: string;
  fields?: string;
  ids?: string;
  filters?: string;
  depth?: number;
};

// オブジェクト形式のクエリ用
// https://document.microcms.io/content-api/get-content
export type GetContentQuery = {
  draftKey?: string;
  fields?: string;
  depth?: number;
};
// src/types/blog.ts
import { ContentResponse, ListContentsResponse } from "./api";
import { TagResponse } from "./tag";

export type BlogListResponse = ListContentsResponse<BlogResponse>;

export type BlogResponse = ContentResponse<{
  title?: string;
  body?: string;
  tags?: TagResponse[];
}>;

次に aspida で自動生成するためのファイルを記述していきます。デフォルトでは /api にファイルを配置するのですが、今回は /src/api に置きたいため、aspida.config.js を追加します。

// aspida.config.js
module.exports = {
  input: "src/api",
};

/api/v1/blogs の aspida 用の型定義ファイルを作成していきます。

// src/api/v1/blogs/index.ts
export type Methods = {
  get: {
    query?: GetListContentsQuery;
    resBody: BlogListResponse;
  };
};

/api/v1/blogs/{content_id} の aspida 用の型定義ファイルを作成していきます。パスに変数がある場合には _id@string のように _ から始まる変数に、@ 以降は型を指定します。

// src/api/v1/blogs/_id@string/index.ts
export type Methods = {
  get: {
    query?: GetContentQuery;
    resBody: BlogResponse;
  };
};

あとは package.jsonscripts"api:build": "aspida" を追加して yarn api:build を実行するだけです。実行すると src/api/$api.ts が生成されているはずです。

microCMS の API を叩く際に毎回 FetchConfig を書くのは面倒なので、utils/api.ts を作成しておきます。

// utils/api.ts
const fetchConfig: Required<Parameters<typeof aspida>>[1] = {
  baseURL: process.env.MICRO_CMS_HOST,
  throwHttpErrors: true,
  headers: {
    "X-API-KEY": process.env.MICRO_CMS_API_KEY ?? "",
  },
};

export const client = api(aspida(fetch, fetchConfig));

これで、/api/v1/blog を叩きたいときには client.v1.blog.$get() とするだけです。コード補完もきくので非常に快適になりました。

Next.js でページを作る

ホーム

ブログのホームにはブログの一覧を表示させます。

// src/pages/index.tsx
type StaticProps = {
  siteData: SiteDataResponse;
  blogList: BlogListResponse;
};
type PageProps = InferGetStaticPropsType<typeof getStaticProps>;

const Page: NextPage<PageProps> = (props) => {
  // 省略
};

export const getStaticProps: GetStaticProps<StaticProps> = async () => {
  const siteDataPromise = client.v1.sitedata.$get({
    query: { fields: "title" },
  });

  const blogListPromise = client.v1.blogs.$get({
    query: { fields: "id,title" },
  });

  const [siteData, blogList] = await Promise.all([
    siteDataPromise,
    blogListPromise,
  ]);

  return {
    props: { siteData, blogList },
    revalidate: 60,
  };
};

export default Page;

microCMS からビルド時にデータを取得するため、getStaticPropsprops に必要なデータをそれぞれ戻します。ホームではサイトデータとブログ一覧、2 つの API を叩く必要があるので、それぞれのリクエストを Promise.all しています。

revalidate には再生成までの秒数が指定できます。この例では revalidate: 60 と設定されているため、60 秒ごとに 1 回再検証されて再生成できるようになります。

SSG をしたい場合にはこの指定はいりませんが、今回のように更新される API を叩く場合には Webhook を利用して、適切なタイミングで再ビルドさせる必要がでてきます。

また NextPage の型引数には InferGetStaticPropsType<typeof getStaticProps> を指定していますが、このとき GetStaticProps の型引数の指定も忘れないようにしましょう。公式ドキュメントで書かれていたのでNextPage<Foo> のようにしていたコードを 書き換えたのですが GetStaticProps の型引数を省略してしまっていたためすべて any で推論されてしまい、しばらく悩まされました。

ブログ記事

ブログ記事を表示させます。先ほど作成したホームと異なる点は、動的ルートであることです。プレビューモードについては後ほど実装していきます。

// src/pages/blogs/[id]/index.tsx
const Page: NextPage<PageProps> = (props) => {
  const { blog } = props;
  const router = useRouter();

  if (router.isFallback) {
    return <div>Loading...</div>;
  }
  // 省略
};

export const getStaticPaths: GetStaticPaths = async () => {
  return {
    fallback: "blocking",
    paths: [],
  };
};

export const getStaticProps: GetStaticProps<StaticProps> = async (context) => {
  const { params } = context;

  if (!params?.id) {
    throw new Error("Error: ID not found");
  }

  const id = toStringId(params.id);

  try {
    const blog = await client.v1.blogs._id(id).$get({
      query: {
        fields: "id,title,body,publishedAt,tags",
      },
    });
    return {
      props: { blog },
      revalidate: 60,
    };
  } catch (e) {
    return { notFound: true };
  }
};

export default Page;

Next.js では src/pages/blogs/[id]/index.tsx のようにページ名を [id] のようにとすると、動的ルートを作成できます。

Next.js 10 から{ notFound: true } とすることで、404 ページを戻すことができます。それまでのバージョンでは 200 で戻す 404 ページを作成する必要がありました。

fallback には、こちらも Next10 から利用可能となった blocking を指定しています。true が指定されていたときに生じる、初回アクセスで API から取得するデータ部分がない状態で表示される動作をブロックする挙動になります。基本的に true ではなく blocking で指定しておけばよさそうです。

また fallback 中かどうかは NextRouterisFallback で判定できるので、ローディング画面などを出しておくことができます。

プレビューの作成

プレビュー機能の実装は microCMS の公式ブログで紹介されています。microCMS のブログはとてもわかりやすく役立つ記事が多いのでとてもおすすめです。

ここでは、上記のブログでは紹介されていないプレビューモードのときには、テキストリンクを出してプレビューモードの解除ができる機能も実装してみます。

プレビューの API ルートは下記になります。コードは examples にあるコードを参考に作成して型をつけただけのものです。

// src/pages/api/preview.ts
const preview = async (
  req: NextApiRequest,
  res: NextApiResponse
): Promise<void> => {
  if (req.query.secret !== process.env.MICRO_CMS_PREVIEW_SECRET) {
    return res.status(401).json({ message: "Invalid token" });
  }

  const id = toStringId(req.query.id);
  const draftKey = toStringId(req.query.draftKey);
  const post = await client.v1.blogs._id(id).$get({
    query: {
      fields: "id,title,body,publishedAt,tags",
      draftKey,
    },
  });

  if (!post) {
    return res.status(401).json({ message: "Invalid contentId" });
  }

  res.setPreviewData({ ...post, draftKey });
  res.writeHead(307, { Location: `/blogs/${post.id}` });
  res.end();
};

export default preview;

次にプレビューを解除するための API ルートを作成します。こちらも examples にあるコードを参考に作成していますが、プレビューの解除をしたあとにホームへ戻すのではなく、もともとプレビューしていた記事に戻したいので少しコードを加えています。やっていることはクエリに contentId を含め、記事の存在をチェックしてからリダイレクトしているだけです。

// src/pages/api/exit-preview.ts
const exitPreview = async (
  req: NextApiRequest,
  res: NextApiResponse
): Promise<void> => {
  const id = toStringId(req.query.id);
  const post = await client.v1.blogs._id(id).$get({
    query: { fields: "id" },
  });

  res.clearPreviewData();
  res.writeHead(307, { Location: post ? `/blogs/${post.id}` : "/" });
  res.end();
};

export default exitPreview;

実際にプレビューを表示するのはブログの記事を表示していたものと同じファイルを使用するので、先ほどのものに追記していきます。previewDatadraftKey があった場合には、閲覧中である表示とプレビュー解除するためのリンクを追加しただけになります。

// src/pages/blogs/[id]/index.tsx
const Page: NextPage<PageProps> = (props) => {
  const { blog, draftKey } = props;
  const router = useRouter();

  if (router.isFallback) {
    return <div>Loading...</div>;
  }

  return (
    <>
      {draftKey && (
        <div>
          現在プレビューモードで閲覧中です。
          <Link href={`/api/exit-preview?id=${blog.id}`}>
            <a>プレビューを解除</a>
          </Link>
        </div>
      )}
      {/* 省略 */}
    </>
  );
};

export const getStaticPaths: GetStaticPaths = async () => {
  return {
    fallback: "blocking",
    paths: [],
  };
};

export const getStaticProps: GetStaticProps<StaticProps> = async (context) => {
  const { params, previewData } = context;

  if (!params?.id) {
    throw new Error("Error: ID not found");
  }

  const id = toStringId(params.id);
  const draftKey = previewData?.draftKey
    ? { draftKey: previewData.draftKey }
    : {};

  try {
    const blog = await client.v1.blogs._id(id).$get({
      query: {
        fields: "id,title,body,publishedAt,tags",
        ...draftKey,
      },
    });
    return {
      props: { blog, ...draftKey },
      revalidate: 60,
    };
  } catch (e) {
    return { notFound: true };
  }
};

export default Page;

あとは microCMS の設定になりますが、こちらは該当する API の API 設定にある画面プレビューからプレビューのリンク先を指定するだけです。

プレビューを見たい場合には、ブログの投稿画面から画面プレビューで閲覧可能です。

Vercel にデプロイ

Vercel にデプロイしてみます。アカウント作成後、デプロイするリポジトリを選択します。

Next.js で作成しているので、FRAMEWORK PRESET で Next.js を選択するだけで他に設定することなくデプロイできます。今回は microCMS のためのいくつかの環境変数が必要となるので追加しています。

以上で完成です。あとは main ブランチに push するだけでデプロイされます。

さいごに

私は自分で作成しているブログを Next.js で SSG したものを Vercel にデプロイしていて、記事の文章は markdown でリポジトリ内にもたせて、一部の記事以外のコンテンツの API に contentful を利用しています。

今回作成したブログと違いはいくつもありますが、大きく異なる点は microCMS と ISG を試してみた二点になります。

contentful は便利なのですが、今回のブログのような限定的な用途だと目的にたいして、ちょっと機能が大げさすぎることと、どうも自分には若干 UI が使いにくいと感じていたため、いつか別のサービスを試してみたいなと思っていました。microCMS はそういった自分のニーズにとてもマッチしたサービスだと感じました。

また、最近はブログを書く前に調べていったことを zenn のスクラップにまとめているのですが、microCMS についてメモ書きをしていたところ、疑問点について microCMS の中の方にコメントを頂いたり、違和感のある挙動についてツイートしたところ、microCMS の公式ツイッターアカウントに反応いただいて翌日には修正される、と嬉しい体験ができました。(こういうことがあると、とてもそのサービスを好きになっちゃいますよね)

ISG については Vercel にデプロイするのであれば、という前提になってしまいますが、API のデータのちょっとした修正で Webhook を利用してすべてを再ビルドしていたことを考えると、すでに SSG をしているのであれば ISG に切り替えたほうが手軽そうだなと感じました。