Next.js × TypeScript × microCMS × Tailwind CSSでJamStackなブログを作ってみた
概要
こんにちは。まっきんとっしゅです(@mkt_phys)。今回は Next.js と microCMS を使って Jamstack なブログを作ってみました。リポジトリはこちらです(公開日時点のブランチは release/2022_0131 です)。実際のブログはこちら です(今は完全にブログを作ることがゴールになってしまってます泣)。
作ったもの
作成したブログの URL はこちらです。ほんとにシンプルな構成でページとしては記事一覧ページと記事詳細ページだけです笑。具体的には以下の通りです。検索機能とか、タグとかは作ってないです(作ります)。
- 記事一覧ページ
- 記事詳細ページ
- シンタックスハイライト
- SG(Static Generation)
- ISR(Incremental Static Regeneration)
- CSR(Client-Side Rendering 、クライアントフェッチ)
- Google Analytics 連携
やってないこと
ここでやってないことをつらつらと書き並べます。
- Prettier
- Linter
- ダークモード
- 検索機能
- ページネーション
- コメント機能
- 僕の SNS へのリンク
- PWA
特に上 2 つは追加しないとダメですね。今後記事書きます!
主なライブラリのバージョン
作成したブログに用いた主なライブラリのバージョンはこちらです。
- Next : 12.0.4
- React : 17.0.2
- TypeScript : 4.5.2
- SWR : 1.1.2
- Tailwind CSS : 2.2.19
プロジェクトのセットアップ
Next のプロジェクトの作成
なにはともあれ Create Next App ですね。
npx create-next-app jamstack-blog
いつの間にかデフォルトで yarn ではなく npm になってました。このコマンドでオプションで--ts
をつけていれば TypeScript が導入できたのですがうっかりオプションをつけるのを忘れていました。
こちらのサイトに従って既存のプロジェクトに TypeScript を導入します。
touch tsconfig.json # プロジェクトのルートでtsconfig.jsonを作成
npm install typescript @types/node @types/react # TypeScriptと型の情報をインストール
npm run dev # tsconfig.jsonに書き込まれる。
デフォルトだと tsconfig.json の strict が false になっているのでこちらを true に変更しました。true に変更することで暗黙的なany
型が使われている場合にエラーを吐き出すようになります。TypeScript は初心者ですが TypeScript に少しでも慣れるために制限を厳しくしてます笑。このオプションのせいでビルドの時にエラーがでまくりましたが自分のためと思って頑張りました。
{
"compilerOptions": {
"target": "es5",
"lib": [
"dom",
"dom.iterable",
"esnext"
],
~~省略~~
"strict":true // こちらを変更
}
Tailwind CSS の導入
公式サイトの手順に従って Tailwind CSS を導入します。
- Tailwind CSS のインストール
npm install -D tailwindcss@latest postcss@latest autoprefixer@latest
npx tailwindcss init -p
2 つ目のコマンドでルート直下に tailwind.config.js と postcss.config.js がつくられます。
- tailwind.config.js の編集
purge
オプションで- どのディレクトリに置いた
- どの拡張子のものに
Tailwind CSS を適用させるかを記述します。また実装で使われることがなかった Tailwind CSS のクラスは実装の時にビルドされなくなります。
module.exports = {
purge: ["./pages/**/*.{js,ts,jsx,tsx}", "./components/**/*.{js,ts,jsx,tsx}"],
darkMode: false, // or 'media' or 'class'
theme: {
extend: {
fontFamily: {
rock: ["Rock Salt"],
},
},
},
variants: {},
plugins: [],
};
これでセットアップは完了です。
なおfontFamily
の部分はかっちょいい GoogleFont を使うために定義しています。font-rock
のクラスを適用することでかっちょいいフォントになります。
_document.tsx
)
Google Analytics と Favicon の設定(Google Analytics と Favicon は Head タグに書く必要があるのでここに書きます。
_document.tsx
Google Analytics とファビコンに関する記述はコンポーネント化しているので_document.tsx
ではおれを読み込んでいるだけです。
import NextDocument, { Html, Head, Main, NextScript } from "next/document";
import Favicon from "../components/Favicons";
import GA from "../components/GA";
type Props = {};
class Document extends NextDocument<Props> {
render() {
return (
<Html lang="ja">
<Head>
{/*Google Analyticsのコンポーネント*/}
<GA />
{/*ファビコン関連のコンポーネント*/}
<Favicon />
</Head>
<body className="leading-relaxed box-content">
<Main />
<NextScript />
</body>
</Html>
);
}
}
export default Document;
gtag.ts
)
Google Analytics のイベントを発火させる関数(_document.tsx
にあったGA
コンポーネントで使う関数を作ります。型を使いたいのでまず最初に型情報を import します。
npm i @types/gtag.js
Vercel のリポジトリにサンプルがあったのでそのまま使います。
export const GA_TRACKING_ID = process.env.NEXT_PUBLIC_GOOGLE_ANALYTICS_ID || "";
//参考: https://developers.google.com/analytics/devguides/collection/gtagjs/pages
export const pageview = (url: string): void => {
if (!GA_TRACKING_ID) return;
window.gtag("config", GA_TRACKING_ID, {
page_path: url,
});
};
//参考: https://developers.google.com/analytics/devguides/collection/gtagjs/events
type GaEventProps = {
action: string;
category: string;
label: string;
value?: number;
};
export const event = ({
action,
category,
label,
value,
}: GaEventProps): void => {
if (!GA_TRACKING_ID) return;
window.gtag("event", action, {
event_category: category,
event_label: label,
value,
});
};
型を import したのはwindow.gtag
を使うためです。
Google Analytics
こちらに Vercel が作成したサンプルコードの中に GA を組み込んだ物があったのでそれを採用します。
import { VFC } from "react";
import { GA_TRACKING_ID } from "../lib/gtag";
import Script from "next/script";
const GA: VFC = () => {
return (
<>
{GA_TRACKING_ID && (
<>
<Script
strategy="afterInteractive"
src={`https://www.googletagmanager.com/gtag/js?id=${GA_TRACKING_ID}`}
/>
<Script
strategy="afterInteractive"
dangerouslySetInnerHTML={{
__html: `
window.dataLayer = window.dataLayer || [];
function gtag(){dataLayer.push(arguments);}
gtag('js', new Date());
gtag('config', '${GA_TRACKING_ID}', {
page_path: window.location.pathname,
});`,
}}
/>
</>
)}
</>
);
};
export default GA;
Script
タグ(デフォルトで用意してある頭文字が小文字の script ではない)にはstrategy
と呼ばれる属性を設定することができます。この属性には 3 つのオプションがあります。それぞれ
-
beforeInteractive
:ページが操作可能になる前に src 属性に書かれたスクリプトをロードする -
afterInteractive
:ページが操作可能になった直後 src 属性に書かれたスクリプトをロードする -
lazyOnload
:ページが操作可能になってアイドル状態になった後に src 属性に書かれたスクリプトをロードする
詳しくはNext の公式ページ やこちらの記事のページをご覧ください。
Favicon
こちら の記事で紹介されている Favicon Generator というサイトを使ってファビコンの生成を行いました。こちらのサイトに画像をアップロードすると、デスクトップ用、モバイル用などのファビコンが生成されます。
import { VFC } from "react";
const Favicon: VFC = () => {
return (
<>
<link
rel="apple-touch-icon"
sizes="180x180"
href="/favicons/apple-touch-icon.png"
/>
<link
rel="icon"
type="image/png"
sizes="32x32"
href="/favicons/favicon-32x32.png"
/>
<link
rel="icon"
type="image/png"
sizes="16x16"
href="/favicons/favicon-16x16.png"
/>
<link rel="manifest" href="/favicons/site.webmanifest" />
<link
rel="mask-icon"
href="/favicons/safari-pinned-tab.svg"
color="#5bbad5"
/>
<meta name="msapplication-TileColor" content="##3B1EEc" />
<meta name="theme-color" content="#ffffff" />
</>
);
};
export default Favicon;
_app.tsx
)
Google Analytics をページ遷移時にも対応させる(これも Vercel の(サンプル)[https://github.com/vercel/next.js/blob/canary/examples/with-google-analytics/pages/_app.js]にあります。
import { useEffect } from "react";
import { useRouter } from "next/router";
import { AppProps } from "next/app";
import * as gtag from "../lib/gtag";
import "../styles/globals.scss";
const MyApp = ({ Component, pageProps }: AppProps) => {
// Google Analyticsをページ遷移時にも対応させる
const router = useRouter();
useEffect(() => {
const handleRouteChange = (url: string) => {
gtag.pageview(url);
};
router.events.on("routeChangeComplete", handleRouteChange);
return () => {
router.events.off("routeChangeComplete", handleRouteChange);
};
}, [router.events]);
return <Component {...pageProps} />;
};
export default MyApp;
router.events.on
、の第一引数はイベントを発火させるタイミングを表ています。routeChangeComplete
はルーティングが完了した時にイベントを発火させます。もう 1 つのrouter.events.off
でイベントを unsubscribe します。
Layout.tsx
)
ブログサイト全体のレイアウトを決める (単純なレイアウトです。上からヘッダー、コンテンツ、フッターがあるレイアウトです。
import { ReactNode, VFC } from "react";
import Head from "next/head";
import Header from "../common/Header";
import Footer from "../common/Footer";
interface Props {
children: ReactNode;
title?: string;
}
const Layout: VFC<Props> = ({ children, title }) => {
return (
<>
<Head>
<title>{title}</title>
</Head>
<div className={`flex bg-gray-100 flex-col h-screen`}>
<Header />
<div className="flex-1 px-4 md:px-18 xl:px-36 bg-gray-100 blogContent">
<main>{children}</main>
</div>
<Footer />
</div>
</>
);
};
export default Layout;
h1,h2,h3 タグにグローバルなスタイルを適用させたかったのでblogContent
というクラスを作成しました。blogContent
の下の h1,h2,h3 タグにスタイルが適用されるようにしています。
global.scss
@tailwind base;
@tailwind components;
@tailwind utilities;
// ブログ詳細のスタイル
@layer base {
.blogContent {
h1 {
@apply text-4xl sm:text-5xl font-bold mt-8 mb-4;
}
h2 {
@apply mb-0 text-xl sm:text-2xl font-bold;
}
h3 {
@apply text-xl sm:text-lg;
}
}
}
pages/index.tsx
)
記事一覧ページ(このファイルで SG、ISR、CSR を実現しています。
まず SG と ISR にはgetStaticProps
を使って実現しています。この後すぐ出てきますがgetStaticProps
の型はInferGetStaticPropsType
を使って定義しています。
export const getStaticProps = async () => {
const data = await getAllArticles();
return {
props: { staticArticles: data.contents },
revalidate: 3,
};
};
getAllArticles()
は microCMS から全記事記事を取得する処理です。こちらは外部ファイル化しました。
getAllArticles()
export const getAllArticles = async (): Promise<CONTENTS> => {
const options: AxiosRequestConfig = {
url: `${process.env.API_URL}/blog`,
method: "GET",
headers: { "X-MICROCMS-API-KEY": process.env.API_KEY! },
};
const res = await axios(options);
const { data }: { data: Promise<CONTENTS> } = res;
return data;
};
ISR ってものすごくありがたい機能なのですが再ビルドが走るのが1度アクセスがあってからなんですよね。ということは microCMS で記事をアップしてから最初に見てくれる人には古いコンテンツを見せることになってしまいます。むしろ最初にきてくれた人に最新の記事を見せたいので CSR も実装します(正直なところ小さい自分のブログなので更新後自分でアクセスしちゃえば問題ないといえば問題ないですね..。このせいで Lighthouse の点数も下がっているような気がします)。CSR には SWR を使うことにしました。
記事を表示している部分も含めると以下の通りです。ちなみに一行目にあるのがgetStaticProps
で取得した値の型です。staticArticles はgetStaticProps
で取得した記事です。それをuseSWR
のfallbackData
に設定することで CSR するまえのデータを画面に表示しています。その後 CSR した最新のデータを表示するという流れです。なので記事の公開後に初めてブログにアクセスした人は一瞬古い状態のサイトが見えます。
CSR を実装しているのは以下の部分です。
const Home: NextPage<Props> = ({ staticArticles }) => {
const { data: articles, mutate } = useSWR<ARTICLE[]>("/api/blog/", fetcher, {
fallbackData: staticArticles,
});
useEffect(() => {
//SWRで取得するデータを最新化する
mutate();
}, [mutate]);
return <>...</>
}
CSR で記事を取得する際も microCMS のシークレットキーが必要です。単純にシークレットキーを設定した環境変数にNEXT_PUBLIC
をつければ CSR を実装できます。しかしNEXT_PUBLIC
のプレフィックスをつけると JavaScript にインラインで公開されてしまいます(ブラウザで容易にシークレットキーが見つけられてしまう)。そこで今回は CSR は API ルートを経由することにしました。
page/
ディレクトリの下にapi/
ディレクトリを作るとそこが API のエンドポイントとして扱われます。
やっていることは単純でapi/blog
にアクセスしたら全記事を返却しているだけです。(getStaticProps
で実装したものとほぼ同じ内容です)
import type { NextApiRequest, NextApiResponse } from "next";
import { getAllArticles } from "../../../lib/articles";
const updateTopPage = async (req: NextApiRequest, res: NextApiResponse) => {
// microCMSから全データを取得する
const data = await getAllArticles();
res.status(200).json(data.contents);
};
export default updateTopPage;
以上を全てまとめたものが以下のコードです。
index.tsx
import { GetStaticPaths, InferGetStaticPropsType, NextPage } from "next";
import { useRouter } from "next/router";
import { getAllArticleIds, getArticleById } from "../lib/articles";
import { formatYYYYMMDD } from "../lib/dayjs";
import { highlightByHighlightJs } from "../lib/highlightCode";
import "highlight.js/styles/hybrid.css";
import Layout from "../components/top/Layout";
type Props = InferGetStaticPropsType<typeof getStaticProps>;
const Home: NextPage<Props> = ({ staticArticles }) => {
const { data: articles, mutate } = useSWR<ARTICLE[]>("/api/blog/", fetcher, {
fallbackData: staticArticles,
});
useEffect(() => {
//SWRで取得するデータを最新化する
mutate();
}, [mutate]);
return (
<Layout title="Mkt Memo">
<div className="py-2 space-y-4">
{articles?.map((article) => {
return (
<Link href={`/${article.id}`} key={article.id}>
<a className="block">
<Card {...article} />
</a>
</Link>
);
})}
</div>
</Layout>
);
};
export default Home;
export const getStaticProps = async () => {
const data = await getAllArticles();
return {
props: { staticArticles: data.contents },
revalidate: 3,
};
};
なお、NextPage 型は
Page
type, use it as a guide to createpages
.
だそうです。pages ディレクトリ下で使うファイルだということを明示するために使う型のようです。VFC を使ってもエラーは出なかったのでどちらでも問題はないのかなと思います。
pages/[id].tsx
)
記事詳細ページ(このファイルでシンタックスハイライトや存在しないページへアクセスした時の対応をしています。
ビルド時に生成するパスの取得
今回作成したブログの URL は記事の ID を採用しています(pages/[id].tsx
のようにファイルを作成して Dynamic Routes を利用しています)。ビルドする前(HTML を生成する前)に Next 側は記事の ID つまり[id]
に入る部分が何がわからないのでgetStaticPaths
を用いて以下のように全記事の ID を取得します。
export const getStaticPaths: GetStaticPaths = async () => {
const paths = await getAllArticleIds();//全ての記事のIDを取得
return { paths, fallback: true };
};
なおgetAllArticleIds
は以下の通りです。
getAllArticleIds
export const getAllArticleIds = async (): Promise<ArticleId[]> => {
const options: AxiosRequestConfig = {
url: `${process.env.API_URL}/blog`,
method: "GET",
headers: { "X-MICROCMS-API-KEY": process.env.API_KEY! },//!をつけてnullでないことを明示する
};
const res = await axios(options);
const articles: ARTICLE[] = res.data.contents;
//IDだけを抽出して返却
return articles.map((article) => {
return {
params: {
id: String(article.id),
},
};
});
};
getStaticPaths
で取得したpaths
の配列の要素の数getStaticProps
がビルド時に実行されます。
export const getStaticProps = async ({ params }: ParamType) => {
//記事のIDを元に記事を取得(params.idのidは[id].tsxのidと対応している)
const article = await getArticleById(params.id);//getArticleByIdのメソッドは後述します。
//記事が取得できなかった場合はトップページへリダイレクトする
if (!article) {
return {
redirect: {
destination: "/",
permanent: false,
},
};
}
//シンタックスハイライトをつける
const body = highlightByHighlightJs(article.body);
return {
props: {
article: { ...article, body },
},
revalidate: 1,
};
};
ビルド時に生成されなかったページにアクセスしようとした時の対応
getStaticProps
はビルド時に実行されますがビルド時以外にも実行される場合があります。それは以下の 2 つの条件を満たす場合です。
-
getStaticPaths
の返り値をfallback:true
にしている -
getStaticPaths
のpaths
の中に含まれていないURL にアクセスした
ビルド時以外に実行されて何が嬉しいかを説明します。例えば
- ビルド前:ID が1、2、3(3記事の HTML が生成された)
の場合を考えます。ビルド後に ID が4の記事が公開されたあと/4
の URL にアクセスした時にgetStaticProps
が実行されて記事が取得され、ブラウザに表示されます。
getStaticProps
が実行されている間にローディング画面などの待機画面を表示したい時はnext/router
のrouter.isFallback
を用います。記事が取得できるまでは「Loading」が表示されます。
const Blog: NextPage<Props> = ({ article }) => {
const router = useRouter();
if (router.isFallback || !article) {
return <div>Loading...</div>;
}
}
まだ問題が残っていた
存在しない URL にアクセスした時の対処がまだでした。今のままだと/fefefe
などにアクセスした場合は ID がfefefe
の記事を探しに行きますがそのような記事はないのでエラーで落ちてしまいます。つまり記事を取得しにいく実装でエラーハンドリングが必要です。それを踏まえて作成した ID を元に記事を取得するメソッドが以下の通りです。
export async function getArticleById(id: string): Promise<ARTICLE> {
const options: AxiosRequestConfig = {
url: `${process.env.API_URL}/blog/${id}`,
method: "GET",
headers: { "X-MICROCMS-API-KEY": process.env.API_KEY! },
};
let res: AxiosResponse<ARTICLE>;
try {
res = await axios(options);
} catch (e) {
//記事が取得できなかった場合の処理
if (axios.isAxiosError(e) && e.response?.status === 404) {
return e.response?.data;
}
}
return res!.data;
}
上記の実装では記事を取得できなかった場合はnull
を返しています。このnull
を利用してトップページへ返す実装をしています。(404 ページを作る必要がユーザーフレンドリーですよね..)
export const getStaticProps = async ({ params }: ParamType) => {
const article = await getArticleById(params.id);
//記事が取得できなかった場合はトップページへリダイレクトする
if (!article) {
return {
redirect: {
destination: "/",
permanent: false,
},
};
}
///省略
}
[id].tsx
の全容
最後に全容をお見せします。
[id].tsx
import { GetStaticPaths, InferGetStaticPropsType, NextPage } from "next";
import { useRouter } from "next/router";
import { getAllArticleIds, getArticleById } from "../lib/articles";
import { formatYYYYMMDD } from "../lib/dayjs";
import { highlightByHighlightJs } from "../lib/highlightCode";
import "highlight.js/styles/hybrid.css";
import Layout from "../components/top/Layout";
type Props = InferGetStaticPropsType<typeof getStaticProps>;
const Blog: NextPage<Props> = ({ article }) => {
const router = useRouter();
if (router.isFallback || !article) {
return <div>Loading...</div>;
}
const { title, body, createdAt, updatedAt } = article;
return (
<Layout title={title}>
<div className="p-4 md:p-12 bg-white rounded">
<div className="text-center text-4xl font-bold mb-2">{title}</div>
<div className="space-x-2 text-right">
<div className="">作成日 : {formatYYYYMMDD(createdAt)}</div>
<div className="">更新日 : {formatYYYYMMDD(updatedAt)}</div>
</div>
<div
dangerouslySetInnerHTML={{
__html: `${body}`,
}}
></div>
</div>
</Layout>
);
};
export default Blog;
// 静的生成のためのパスを指定する(ビルド時に実行)
export const getStaticPaths: GetStaticPaths = async () => {
const paths = await getAllArticleIds();
return { paths, fallback: true };
};
interface ParamType {
params: {
id: string;
};
}
//params.idでダイナミックルートの値が取得できる([id].tsxの[id]の部分)
export const getStaticProps = async ({ params }: ParamType) => {
//記事のIDを元に記事を取得(params.idのidは[id].tsxのidと対応している)
const article = await getArticleById(params.id);
//記事が取得できなかった場合はトップページへリダイレクトする
if (!article) {
return {
redirect: {
destination: "/",
permanent: false,
},
};
}
//シンタックスハイライトをつける
const body = highlightByHighlightJs(article.body);
return {
props: {
article: { ...article, body },
},
revalidate: 1,
};
};
作成日、更新日をフォーマットするメソッドformatYYYYMMDD
とシンタックスハイライトをつけるメソッドhighlightByHighlightJs
も載せておきます。
formatYYYYMMDD
import dayjs from "dayjs";
import ja from "dayjs/locale/ja";
//日本に言語設定
dayjs.locale(ja);
/**
* YYYY年MM月DD日にフォーマットする
* @param date 日付
* @returns YYYY年MM月DD日にフォーマットされた日付
*/
export const formatYYYYMMDD = (date: Date | string) => {
const dateDayjs = dayjs(date);
return dateDayjs.format("YYYY年MM月DD日");
};
highlightByHighlightJs
export const highlightByHighlightJs = (content: string) => {
const $ = cheerio.load(content);
$("pre code").each((_, elm) => {
const result = hljs.highlightAuto($(elm).text());
$(elm).html(result.value);
$(elm).addClass("hljs");
});
return $.html();
};
まとめ
まだまだ実装しないといけないことはたくさんありますが
- 記事一覧ページ
- 記事詳細ページ
があるブログサイトを作りました。キモになるのはビルド時にサーバサイドで実行される
getStaticPaths
getStaticProps
でした。こいつらのおかげで SG や ISR が実装できました。また SWR も使って CSR も実装しています。CSR を使って ISR の弱点をうまくカバーすることができました。
作りながら思いましたが Next はほんとにすごいフレームワークですね笑。僕みたいな初心者でもそこそこのブログを作ることができました。
なにか間違いやアドバイスなどありましたらお気軽にコメントお願いします!
ブログを作って終わりにならないように記事をたくさん書いていきたいと思います!(Zenn との使い分けはどうしようか..)
参考文献
- https://maku.blog/p/ny9fmty/
- https://v2.tailwindcss.com/docs/guides/nextjs
- https://qiita.com/dtakkiy/items/dd161e2646695b387277
- https://github.com/vercel/next.js/blob/canary/examples/with-google-analytics/lib/gtag.js
- https://github.com/vercel/next.js/blob/canary/examples/with-google-analytics/pages/_app.js
- https://www.xserver.ne.jp/blog/google-analytics-ga4-setting/
- https://panda-program.com/posts/nextjs-google-analytics#nextjsでgoogle-analyticsを使えるようにする
- https://nextjs.org/docs/basic-features/script
- https://zenn.dev/aiji42/articles/9a6ab12ab5f6e6
- https://qiita.com/purpleeeee/items/cd9aca1ae735ad678355
- https://github.com/vercel/next.js/blob/canary/examples/with-google-analytics/pages/_app.js
- https://blog.microcms.io/syntax-highlighting-on-server-side/
- https://nextjs.org/docs/basic-features/environment-variables
- https://nextjs-ja-translation-docs.vercel.app/docs/api-routes/introduction
- https://qiita.com/matamatanot/items/1735984f40540b8bdf91
- https://qiita.com/thesugar/items/47ec3d243d00ddd0b4ed
- https://zenn.dev/ria/articles/b709ae94e919c76f814a
Discussion