はじめまして、エンジニア歴3年の26歳(男)です。
本ブログ初投稿の記事ということで、このブログで採用しているJamstack構成なブログの構築手順について、書き記していこうと思います。
なおこういうブログ構築手順は既にたくさん存在していますが、自分のアウトプット兼備忘録として残そうと思います。
記事の目的と注意点
Next.jsとmicroCMSでJamstackブログを構築する方法を解説します。
手順を示すことが目的なので、コードやCSSの詳細な解説は行いません。
またCSSによるスタイリングも最低限となっています。
この記事でやること
- 開発環境の構築
- microCMSからデータを取得するAPIを実装
- markdownで書いた記事を表示するページを実装
そもそもJamstackとは
記事データなどの動的コンテンツをAPIから取得し、そのデータを事前にHTMLに埋め込み静的HTMLを返却するアーキテクチャのことです。
従来の構成はサーバーへのアクセスごとに(データベースなどから)データを取得していましたが、Jamstackではアクセスに対して静的HTMLを返すだけです。
SPAにも似ていますが、SPAは動的コンテンツをAPIから取得するのに対し、Jamstackはその動的コンテンツを事前にHTMLに埋め込みます。
使用する技術
このブログでは以下の技術を使用しているので、本手順でも同様のものを使用します。
フレームワーク
- 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を使っているなら以下の拡張機能を入れるのが便利です。
3. TypeScriptで絶対importを有効にする
相対importだけだと不便なので、絶対importできるようにします。
tsconfig.json
のcompilerOptions
にbaseUrl
を追記します。(関係ない部分は省略)
{
// ...
"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つ作成しました。(内容は適当です)
記事コンテンツを作成したら「下書きを追加」→「公開」ボタンを押下して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 client
はmicrocms-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].tsx
のgetStaticProps()内に定義します。
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)
まだ殺風景ですが、これから見た目を調整してそれっぽくしていきます。
長くなってきたので、次回の記事でシンタックスハイライトと記事一覧画面の実装を書こうと思います。
感想
初投稿で、文章を書くのにかなり時間がかかり疲れました...
今までにQiitaに投稿したことはありますが、記事を書くことの大変さを改めて感じました。
拙い文章ですが、最後までお読みいただきありがとうございました!
参考リンク
microCMSブログ
microCMS + Next.jsでJamstackブログを作ってみよう
microCMS
GET API
GitHub
remark