Next.jsとmicroCMSで技術ブログを作る(1)

公開 2022年01月29日更新 2022年02月07日

Next.jsReact.jsTypeScriptmarkdownJamstack

はじめまして、エンジニア歴3年の26歳(男)です。
本ブログ初投稿の記事ということで、このブログで採用しているJamstack構成なブログの構築手順について、書き記していこうと思います。
なおこういうブログ構築手順は既にたくさん存在していますが、自分のアウトプット兼備忘録として残そうと思います。

記事の目的と注意点

Next.jsとmicroCMSでJamstackブログを構築する方法を解説します。
手順を示すことが目的なので、コードやCSSの詳細な解説は行いません。
またCSSによるスタイリングも最低限となっています。

この記事でやること

  • 開発環境の構築
  • microCMSからデータを取得するAPIを実装
  • markdownで書いた記事を表示するページを実装

そもそもJamstackとは

記事データなどの動的コンテンツをAPIから取得し、そのデータを事前にHTMLに埋め込み静的HTMLを返却するアーキテクチャのことです。
従来の構成はサーバーへのアクセスごとに(データベースなどから)データを取得していましたが、Jamstackではアクセスに対して静的HTMLを返すだけです。
SPAにも似ていますが、SPAは動的コンテンツをAPIから取得するのに対し、Jamstackはその動的コンテンツを事前にHTMLに埋め込みます。

参考
Jamstackって何なの?何がいいの?

使用する技術

このブログでは以下の技術を使用しているので、本手順でも同様のものを使用します。

フレームワーク

  • Next.js 12.0.8
  • React.js 17.0.2

言語

  • TypeScript 4.5.4

コンテンツ管理

  • microCMS

また開発補助ツールとして以下を使用します。

  • eslint 8.7.0 (構文エラーを静的にチェックするツール)
  • prettier 2.5.1 (コードを整形してくれるツール)

1. プロジェクトを作成

以下を実行してNext.jsの雛形をblog-sampleという名前で作成します。

npx create-next-app blog-sample --typescript
cd blog-sample

2. ESLintとPrettierを導入

ESLintのプラグインとPrettierを入れます。

npm i -D eslint-plugin-react @typescript-eslint/eslint-plugin @typescript-eslint/parser prettier eslint-config-prettier

 
プロジェクトルートに.prettierrc.jsを以下の内容で作成します。(.prettierrc.jsonがある場合は削除します)

module.exports = {
    trailingComma: "es5",
    tabWidth: 4,
    semi: true,
    singleQuote: false,
}

このファイルはフォーマットルールを定義します。
私は4スペースのインデントが好みなので、そのように設定しています。

 
ESLintの設定も定義します。
プロジェクトルートに.eslintrc.jsを作成します。(.eslintrc.jsonがある場合は削除します)
私は以下のように定義しました。

module.exports = {
    env: {
        browser: true,
        es2021: true,
        node: true,
    },
    extends: [
        "next/core-web-vitals",
        "eslint:recommended",
        "plugin:react/recommended",
        "plugin:@typescript-eslint/recommended",
        "prettier",
    ],
    parser: "@typescript-eslint/parser",
    parserOptions: {
        ecmaFeatures: {
            jsx: true,
        },
        ecmaVersion: 12,
        sourceType: "module",
    },
    plugins: ["react", "@typescript-eslint"],
    rules: {
        "react/prop-types": "off",
    },
    settings: {
        react: {
            version: "detect",
        },
    },
};

 
またVSCodeを使っているなら以下の拡張機能を入れるのが便利です。

eslint-extension
prettier-extension

3. TypeScriptで絶対importを有効にする

相対importだけだと不便なので、絶対importできるようにします。
tsconfig.jsoncompilerOptionsbaseUrlを追記します。(関係ない部分は省略)

{
    // ...
    "compilerOptions": {
        "baseUrl": "./",
    },
    // ...
}

 
試しにpages/index.tsxのstylesのimportを以下のように変更してみて、警告が出なければokです。

import type { NextPage } from "next";
import Head from "next/head";
import Image from "next/image";
- import styles from "../styles/Home.module.css";
+ import styles from "styles/Home.module.css";

4. microCMSに記事を登録する

記事などのコンテンツはmicroCMSに登録します。
以下を参考に会員登録し、リスト形式のAPIを作成します。
microCMS + Next.jsでJamstackブログを作ってみよう

記事はmarkdownで書きたいので、Next.jsのSSGでhtmlにパースします。
以下のようにAPIの項目を1つ作成しました。(内容は適当です)
microcms-api-content

記事コンテンツを作成したら「下書きを追加」→「公開」ボタンを押下してAPIを公開します。
またmicroCMSの管理画面でAPIキーを確認し、それをプロジェクトルートの.env.development.localに書き込みます。

API_KEY=xxxxxxxxxxxxxxxxxxxx

5. ソースコードからAPIにアクセスする

microCMSのAPIにアクセスするため、公式のmicrocms-js-sdkパッケージをインストールします。

npm i microcms-js-sdk

 
types/api.tsに記事データの型を用意します。

export interface Article {
    id: string;
    title: string;
    content: string;
    revisedAt: string;
    publishedAt: string;
}

 
lib/client.tsにmicroCMSの記事データを取得する処理を実装します。

import { createClient } from "microcms-js-sdk";
import type { Article } from "types/api";

export const client = (() => {
    const apiKey = process.env.API_KEY; // APIキーを取得
    if (apiKey == null) {
        throw new Error(
            "Error in client/client.ts client() : API_KEYが未定義です"
        );
    }
    return createClient({
        serviceDomain: "blog-sample", // microCMSのAPIのドメインを指定
        apiKey: apiKey,
    });
})();

export const getArticleContent = async (id: string) => {
    const res = await client.getObject({
        endpoint: "posts", // 作成したAPIのendpointを指定
        queries: {
            // 取得対象のフィールドを指定
            fields: [
                "title",
                "content",
                "publishedAt",
                "revisedAt",
            ],
            filters: `id[equals]${id}`,
        },
    });
    // 要素数が0の可能性もあるのでチェックが必要だが、本手順では省略
    return createArticle(res.contents[0]);
};

/* eslint-disable-next-line @typescript-eslint/no-explicit-any */
const createArticle = (response: any) => {
    return {
        id: response.id ?? "",
        title: response.title ?? "",
        content: response.content ?? "",
        publishedAt: response.publishedAt ?? "",
        revisedAt: response.revisedAt ?? "",
    } as Article;
};

const clientmicrocms-js-sdkパッケージを使用してAPIを実行するオブジェクトを生成しています。
getArticleContent()は上記のclientオブジェクトを使用してリクエストを実行しています。
リクエストクエリでfieldsを定義することで、取得するフィールドを指定できます。
microCMSの無料プランでは毎月のAPIの通信量に制限があり、また1度のレスポンスのサイズ上限が5MBと決まっているので、指定したほうが良いと思います。
microCMS 料金
microCMS GET API limit

APIからのレスポンスは型が不明瞭であるため、それをArticle型に変換するためのcreateArticle()関数を定義しています。
この関数は引数をanyで取りますが、このままだとESLintの警告が出てしまいます。
実装上この部分は警告を出さないようにしたいので、以下のコメントを付与しています。

/* eslint-disable-next-line @typescript-eslint/no-explicit-any */

このコメントがあると、次の行の@typescript-eslint/no-explicit-anyという警告を無視することができます。

またコンテンツの全てのフィールドを取得するわけではないので、取得しなかったフィールド(undefined)には空文字列を代入するようにしています。

id: response.id ?? ""

これはNull合体演算子といい、左辺がnullまたはundefinedの場合は右辺を返し、それ以外の場合は左辺を返します。

6. 記事ページを作成する

記事ページはmarkdownで書いてhtmlにパースして返却するので、パースに必要なライブラリを入れます。
本手順ではremarkを使用します。

npm i unified remark-breaks remark-parse remark-rehype rehype-stringify
npm i -D @types/jsdom

 
Next.jsではpagesディレクトリ下にファイルを置くと、ファイル名がそのままルーティングに使われます。
つまりpages/mypage.tsxを作成すると、そのページにはhttps://<domain>/mypageでアクセスできるようになります。
しかし1記事ごとに1つの.tsxファイルを作成するのは面倒です。
Next.jsではそのようなときのために動的なパス生成ができます。
例えば、pages/[id].tsxというファイルを作成すると[id]の部分が動的にURLパスとして使用されるというものです。
文章だけだとぴんとこないと思うので、実装の具体例を示します。

動的パスを生成するにはNext.jsのgetStaticPaths()関数を使用します。

import React from "react";
import { GetStaticPaths } from "next";
import { ParsedUrlQuery } from "node:querystring";
import { getArticleIds } from "lib/client";
import type { Article } from "types/api";

interface Params extends ParsedUrlQuery {
    id: string;
}

export const getStaticPaths: GetStaticPaths<Params> = async () => {
    const articleIds: Article[] = await getArticleIds(); // このメソッドは次に実装します

    const paths = articleIds.map((article) => ({
        params: {
            id: `${article.id}`, // これが"/posts/[id]"というパスに使われる
        } as Params,
    }));

    return { paths, fallback: false };
};

getStaticPaths()はParsedUrlQuery型かそれを継承した型を要素とする配列を、pathsフィールドとしてreturnする必要があります。(stringの配列でも可)
そしてその配列が動的に生成されるURLパスとなります。
例えば、["apple", "banana", "cookie"]をpathsとしてreturnしたら以下のパスが自動生成されます。

  • https://<domain>/apple
  • https://<domain>/banana
  • https://<domain>/cookie

 
次に全記事のIDを取得するgetArticleIds()lib/client.tsに実装します。

/* eslint-disable-next-line @typescript-eslint/no-explicit-any */
const toArray = (response: any[]) => {
    const articles: Article[] = [];
    for (const res of response) {
        articles.push(createArticle(res));
    }
    return articles;
};

export const getArticleIds = async () => {
    const res = await client.getList({
        endpoint: "posts",
        queries: {
            limit: 10000,
            fields: ["id"], // 取得対象のフィールド
        },
    });
    return toArray(res.contents);
};

取得したIDの配列をArticle型の配列に変換するため、toArray()関数を定義しています。
これも先程のcreateArticle()関数と同様にany型を使用しているため、ESLintを1行だけ無効化するコメントを付与しています。

各ページのコンテンツはpages/[id].tsxgetStaticProps()内に定義します。

import React from "react";
import { GetStaticProps } from "next";
import { unified } from "unified";
import remarkParse from "remark-parse";
import remarkBreaks from "remark-breaks";
import remarkRehype from "remark-rehype";
import rehypeStringify from "rehype-stringify";
import { getArticleIds, getArticleContent } from "lib/client";
import type { Article } from "types/api";

interface Props {
    article: Article;
}

export const getStaticProps: GetStaticProps<Props, Params> = async ({
    params,
}) => {
    if (params == null) {
        throw new Error(
            "Error in pages/posts/[id].tsx getStaticProps() : paramsが空です"
        );
    }
    const id = params.id;

    // 要素数のチェックが必要だが、本手順では省略
    const articleData = await getArticleContent(id);

    // markdownをhtmlにパース
    const content = await unified()
        .use(remarkParse)
        .use(remarkBreaks)
        .use(remarkRehype)
        .use(rehypeStringify)
        .process(articleData.content);

    articleData.content = String(content);

    return {
        props: {
            article: articleData,
        },
    };
};

 
以下の部分でmarkdownをhtmlにパースしています。
remarkBreaksはmarkdownでの改行を<br>に変換してくれる拡張機能です。

const content = await unified()
    .use(remarkParse)
    .use(remarkBreaks)
    .use(remarkRehype)
    .use(rehypeStringify)
    .process(articleData.content);

 
最後に、フロントエンドで表示するページを実装します。
pages/[id].tsxに以下を追加します。

import styles from "styles/article.module.css";


const ArticlePage: NextPage<Props> = ({ article }) => {
    return (
        <main>
            <div className={styles.titleArea}>
                <p className={styles.title}>{article.title}</p>
                <p>
                    <span className={styles.publishDate}>
                        公開 {article.publishedAt}
                    </span>
                    <span className={styles.updateDate}>
                        更新 {article.revisedAt}
                    </span>
                </p>
            </div>
            <section
                className={styles.article}
                dangerouslySetInnerHTML={{ __html: article.content }}
            ></section>
        </main>
    );
};

 
CSS(styles/article.module.css)は以下のように定義しました。

.titleArea {
    box-sizing: border-box;
    width: calc(100% - 50px);
    height: auto;
    margin: 0 auto;
    padding: 0 10px;
}

.title {
    box-sizing: border-box;
    width: 100%;
    height: auto;
    margin-top: 3vh;
    padding: 0.5em 0.2em;
    font-weight: bold;
    font-size: 3rem;
}

.publishDate {
    display: inline;
    color: rgba(0, 0, 0, 0.5);
    font-size: 1.4rem;
}

.updateDate {
    display: inline;
    color: rgba(0, 0, 0, 0.5);
    font-size: 1.4rem;
    margin-left: 20px;
}

.article {
    box-sizing: border-box;
    width: calc(100% - 50px);
    min-height: 100vh;
    margin: 0 auto;
    padding: 0 10px;
}

 
これで記事ページ(pages/[id].tsx)の最小限の実装は完成です。
コードの全体を以下に示します。

// pages/[id].tsx
import React from "react";
import type { NextPage } from "next";
import { GetStaticProps, GetStaticPaths } from "next";
import { ParsedUrlQuery } from "node:querystring";
import { unified } from "unified";
import remarkParse from "remark-parse";
import remarkBreaks from "remark-breaks";
import remarkRehype from "remark-rehype";
import rehypeStringify from "rehype-stringify";
import { getArticleIds, getArticleContent } from "lib/client";
import type { Article } from "types/api";
import styles from "styles/article.module.css";

interface Props {
    article: Article;
}

interface Params extends ParsedUrlQuery {
    id: string;
}

const ArticlePage: NextPage<Props> = ({ article }) => {
    return (
        <main>
            <div className={styles.titleArea}>
                <p className={styles.title}>{article.title}</p>
                <p>
                    <span className={styles.publishDate}>
                        公開 {article.publishedAt}
                    </span>
                    <span className={styles.updateDate}>
                        更新 {article.revisedAt}
                    </span>
                </p>
            </div>
            <section
                className={styles.article}
                dangerouslySetInnerHTML={{ __html: article.content }}
            ></section>
        </main>
    );
};

export const getStaticPaths: GetStaticPaths<Params> = async () => {
    const articleIds: Article[] = await getArticleIds();

    const paths = articleIds.map((article) => ({
        params: {
            id: `${article.id}`, // これが"/posts/[id]"というパスに使われる
        } as Params,
    }));

    return { paths, fallback: false };
};

export const getStaticProps: GetStaticProps<Props, Params> = async ({
    params,
}) => {
    if (params == null) {
        throw new Error(
            "Error in pages/posts/[id].tsx getStaticProps() : paramsが空です"
        );
    }
    const id = params.id;

    // 要素数のチェックが必要だが、本手順では省略
    const articleData = await getArticleContent(id);

    // markdownをhtmlにパース
    const content = await unified()
        .use(remarkParse)
        .use(remarkBreaks)
        .use(remarkRehype)
        .use(rehypeStringify)
        .process(articleData.content);

    articleData.content = String(content);

    return {
        props: {
            article: articleData,
        },
    };
};

export default ArticlePage;

 
動作確認してみましょう。
プロジェクトルートで以下を実行してサーバーを起動します。

npm run dev

http://localhost:3000/<content-id>にアクセスすると、記事ページが以下のように表示されます。(content-idはmicroCMSの管理画面で作成したコンテンツのID)

first-article-sample

まだ殺風景ですが、これから見た目を調整してそれっぽくしていきます。
長くなってきたので、次回の記事でシンタックスハイライトと記事一覧画面の実装を書こうと思います。

感想

初投稿で、文章を書くのにかなり時間がかかり疲れました...
今までにQiitaに投稿したことはありますが、記事を書くことの大変さを改めて感じました。
拙い文章ですが、最後までお読みいただきありがとうございました!

参考リンク

microCMSブログ
microCMS + Next.jsでJamstackブログを作ってみよう

microCMS
GET API

GitHub
remark


次の記事
Next.jsとmicroCMSで技術ブログを作成する(2)