Next.js を使用して、サブ Blog を Jamstack な Blog としてリニューアルしてみた件

Jamstack な Blog を正式に構築して運用してみたかったので、CMS として Movable Type を使用してサブで書いている Blog を Next.js を使用した Jamstack な Blog としてリニューアルしてみました。実際にどのような実装をしたのかについて、ソースコードを抜粋しつつまとめてみたいと思います。

先月、「Movable Type Advent Calendar 2020」 向けの記事として、Movable Type × Next.js による Jamstack な Blog の作成に関する記事を書きました(下記参考)。

で、この時はサンプルとして書いたので、コード的には本当に基本的な部分しか実装しておらず、実際の運用で使用するには足りない部分だらけだったわけですけども、年末年始のお休みで、きちんと公開するレベルの Web サイトを Next.js で作ってみようということで、この Blog とは別に趣味のサッカー観戦関連の記事を書いているサブの Blog をリニューアルしてみました。

そこで、色々と気付きがあったので、簡単にですがまとめておきたいと思います。ソースコードは抜粋ですし、あとは使ったライブラリやハマった部分の備忘録的な感じで、作り方を詳しく説明するものではありませんが、同じようなことをしようとしている方に何かしら参考になれば。

目次

前提

今回使用した環境は下記の通りです。

  • Movable Type 7 r.4701
  • Node.js 14.15.3 LTS
  • React 17.0.1
  • Next.js 10.0.5

既存のサイトをリニューアルしたため、サーバはそのまま。サーバ側に Node.js 環境はないので、SSG (Static Site Generator / 静的サイト生成) して設置しています。

今回のリニューアルのタイミングで広告 (Google AdSense) を外したことも影響が大きかったとは思いますが、Jamstack にしたことで、リニューアル後の Blog のパフォーマンスは大きく向上しました。

Lighthouse レポートの結果

Movable Type の利用方法

以前書いた記事で作ったサンプルでは、Movable Type Data API を使用して Jamstack な Blog を作成しましたが、今回は Data API は使用せず、MT テンプレートを書いて必要な JSON データを書き出しておいて、それを使用する形にしました。単純に Data API だと使いたいデータだけを取得するが面倒くさかったってのがあります。

Blog のページ構成としては、下記の通り。お勉強を兼ねてなので、一覧ページのページネーション (ページ分割) とか、無限スクロール (スクロールに応じて記事一覧を追加で読み込んで付け足していくやつ) などを実装してみることに。

  • トップページ (新着記事の一覧)
  • カテゴリ別の記事一覧ページ (無限スクロールを実装してみる)
  • 全記事の一覧 (ページネーション含む)
  • 個別記事ページ (前後の記事へのリンクと、新着記事のリストをサイドメニューに設置)
  • サイト内検索 (Google カスタム検索を使用)

Movable Type から書き出すデータは下記の通り。逆にいえば、これらファイル以外は Movable Type から書き出していないため、CMS 側の実装は超シンプルです。

  • 全記事のデータが入った JSON (インデックステンプレート)
  • カテゴリの一覧が入った JSON (インデックステンプレート)
  • カテゴリ別に記事の一覧が入った JSON (記事リストアーカイブを「カテゴリ」別に生成)
  • 各記事のデータが入った JSON (記事アーカイブで生成 / 要するに記事の数だけ JSON データがある)
  • 最新記事 10件だけ入った JSON (インデックステンプレート / 全記事データを使ってもいいけど最新記事だけを取得する軽いデータが欲しかったので)
  • その他、Next.js 側とは関係ないけど、sitemap.xml と RSS フィードは Movable Type から書き出し

もちろん、これらデータは Data API を使用しても取得できますが、前述したとおり、Data API だと取得したものの使わないデータも多いし、JSON データの構造的にも、Next.js 側で実装する際に使いやすい形にしたかったため、あらかじめ JSON ファイルを生成しておくという手法にしました。

ディレクトリ構成

完成した Blog のディレクトリ構成は下記のような感じになりました。構成としてはかなりシンプルです。

project_root
├ .next/
├ node_modules/
├ styles/
│ ├ globals.css
│ └ その他各CSSモジュール
├ pages/
│ ├ _app.js
│ ├ _document.js
│ ├ index.js //トップページ
│ ├ s.js //サイト内の結果表示検索ページ
│ ├ articles/
│ │  ├ [slug].js //記事個別ページ
│ │  └ category/
│ │     └ [categories].js //カテゴリ別一覧ページ
│ └ archive/
│    └ [page].js //全記事一覧ページ(ページ分割)
├ components/
│ ├ Layout.js //全体のレイアウト用
│ ├ NextLink.js
│ ├ Pager.js
│ ├ PayPal.js //プロフィール等に表示されているサポートボタン
│ ├ Profile.js //サイドメニューのプロフィール
│ ├ PreviousLink.js
│ └ Recent.js //サイドメニューの最新記事一覧
├ lib/
│ ├ api.js //JSON 取得のための処理をまとめたもの
│ ├ escapeHtml.js //HTMLエスケープ処理
│ └ gtag.js //Google Analytics用
├ public/
│ └ img
│    ├ favicon.ico
│    └ その他画像
├ out/
├ .gitignore
├ yarn.lock
├ package-lock.json
├ package.json
├ .env.local //JSON の URL などはここに記述
└ next.config.js

トップページ

トップページは最新記事を決まった件数だけ表示するだけなので簡単です。全記事のリストが入った JSON を取得し、その中から先頭の 60件を使用してトップページに掲載します。

ちなみに、この全記事リストの JSON は、entry プロパティに全記事のリスト、page プロパティに JSON-LD で使用するため、Blog のタイトルや概要などの基本情報に加え、最新記事の公開日と更新日に関するデータが入っていますので、それぞれ取得して使用しています。

必要な部分だけ抜き出すと下記のような感じ。

ちなみに、escapeHtml() は HTML エスケープのための関数です。JSON-LD を書き出すために dangerouslySetInnerHTML を使用しているため、この部分に関しては自前でエスケープ処理をするようにしています。

import Head from 'next/head'
import Layout from '../components/Layout'
import { getAllPosts } from '../lib/api'
import { escapeHtml} from '../lib/escapeHtml'

export const getStaticProps = async () => {
    const allPosts = await getAllPosts()
    const COUNT_PER_PAGE = 60
    const end = COUNT_PER_PAGE
    const start = end - COUNT_PER_PAGE
    return {
        props: {
            posts: allPosts.entry.slice(start, end),
            page: allPosts.page
        }
    }
}

const Index = (posts) => {

    const data = posts.posts
    const pageData = posts.page[0]

    return (
        <>
            <Head>
                <script
                    type="application/ld+json"
                    dangerouslySetInnerHTML={{
                        __html: `
                        {
                            "@context": "http://schema.org",
                            "@type": "WebPage",
                            "name": "${escapeHtml(pageData.title)}",
                            "description": "${escapeHtml(pageData.description)}",
                            "headline": "${escapeHtml(pageData.subTitle)}",
                            "datePublished": "${pageData.datePublished}",
                            "dateModified": "${pageData.dateModified}",
                            "image": {
                                "@type": "ImageObject",
                                "url": "${pageData.ogp.image}",
                                "width": ${pageData.ogp.width},
                                "height": ${pageData.ogp.height}
                            },
                            ...(略)...
                        }`
                    }}
                />
            </Head>
            <Layout>
                ...(略)...
                    {data.map(post => (
                        ...(略)...
                    ))}
                ...(略)...
            </Layout>
        </>
    )
}
export default Index

getAllPosts() をはじめ、JSON データを取得するための処理はすべて lib/api.js にまとめてありますので、そこから読み込みます。JSON データの場所は .env ファイルから取得。

const ENTRY = process.env.MT_ENDPOINT_URL_ALL_ENTRY

export const getAllPosts = async () => {
    const res = await fetch(`${ENTRY}`)
    if (!res.ok) {
        console.error(await res.text())
        throw new Error('Failed to fetch API')
    }
    const json = await res.json()
    if (json.errors) {
        console.error(json.errors)
        throw new Error('Failed to fetch API')
    }
    return json
}

記事公開からの経過時間を表示

トップページでは、記事の一覧に 「公開日」 を表示する際、Twitter などでもよくみる、「現在からの経過時間」 で表示するようにしてみました。これには、date-fns を使用します。

npm install date-fns --save

各記事の JSON データには、publishDate として YYYY-MM-DD HH:MM:SS という形式で公開日のデータを入れていたので、下記のようにすることで、簡単に現在時刻からの経過時間を表示することができます。

import { ja } from 'date-fns/locale'
import formatDistanceToNow from 'date-fns/formatDistanceToNow'

const Index = (posts) => {
    return (
        {formatDistanceToNow(new Date(post.publishDate.replace(/-/g, '/')), { addSuffix: true, locale: ja })}
    )
}
export default Index

ちなみに、new Date(post.publishDate.replace(/-/g, '/')) にしてあるのは、当初 new Date(post.publishDate) として実装していたら、Safari だけエラーがでてしまったので、調べた結果、YYYY-MM-DD HH:MM:SSYYYY/MM/DD HH:MM:SS の形式に変換することで問題なくなるという情報を得て変更したものです。下記の記事が参考になりました。

実は手元に iOS 版の Safari しかなく、終わったぜと思って実機で確認したら An unexpected error has occurred って何の参考にもならない表示だけ出してトップページにアクセスできなくなったのでちょっとハマりましたが、ほぼ同じ作りのカテゴリ一覧にはアクセスできていて、大きな違いといえば date-fns くらいかなってことで的を絞って検索したらこの記事が出てきて助かりました。

カテゴリ別一覧ページ

カテゴリ別一覧は、まず、カテゴリの一覧だけを入れた JSON データを取得してきて、getStaticPaths でパスを生成し、それに基づいて、カテゴリ別に生成した記事一覧の JSON データを getStaticProps するという手法で実装しました。

必要な部分だけ抜き出すと下記のような感じ。

import { getAllCategories, getCategoryPosts } from '../../../lib/api'

export const getStaticPaths = async () => {
    const allCategories = await getAllCategories()
    return {
        paths: allCategories.categories.map(post => ({
            params: {
                categories: post.path
            }
        })),
        fallback: false
    }
}

export const getStaticProps = async ({ params }) => {
    const category = params.categories
    const categoryPosts = await getCategoryPosts(category)
    return {
        props: {
            posts: categoryPosts.entry,
            page: categoryPosts.page,
        }
    }
}

const Category = (posts) => {
    ...(略)...
}
export default Category

カテゴリ別の記事一覧を入れた JSON データは、[カテゴリ名]/entryList.json のような形式で書き出していますので、getCategoryPosts() は下記のように書いて各カテゴリ別に JSON を取得しに行くようにしています。

const CATEENTRY = process.env.MT_ENDPOINT_URL_CATEGORY_POSTS

export const getCategoryPosts = async (path) => {
    const res = await fetch(`${CATEENTRY}${path}/entryList.json`)

    if (!res.ok) {
        console.error(await res.text())
        throw new Error('Failed to fetch API')
    }

    const json = await res.json()
    if (json.errors) {
        console.error(json.errors)
        throw new Error('Failed to fetch API')
    }

    return json
}

カテゴリ別一覧ページの無限スクロール

上記までの実装では、単純にカテゴリ別の記事一覧が全件表示されて終わりなのですが、カテゴリによっては数百件の記事が入っている場合もあり、そのままだとちょっとイケてないなということで、無限スクロールを実装してみました。最初に 60件だけ表示しておいて、あとはページの下端近くまでスクロールされたら、60件ずつ記事を追加していく、という仕組みです。

当初、React Hooks を使用して記事を追加していく仕組みを書こうと思ったんですが、ライブラリ使った方が楽そうということで、今回は react-infinite-scroller を導入しました。

npm install react-infinite-scroller --save

ということで、今回も必要な部分だけ抜き出しますが、下記のように、JSON データから 60件ずつ記事データを取得して追加していくコードを書きました。loader は読み込み中に表示する内容ですが、SVG でローディングアニメーションを作成して使用しています。まぁほぼ表示されないんですけども。

import React, { useState } from 'react'
import InfiniteScroll from 'react-infinite-scroller'
import Layout from '../../../components/Layout'

const Category = (posts) => {

    const COUNT_PER_PAGE = 60

    const loader = <div className={styles.loader} key={0}><img src="/img/loading.svg" alt="Loading ..." /></div>

    const [item, setList] = useState([])
    const [hasMore, setHasMore] = useState(true)

    const loadMore = (page) => {

        const end = COUNT_PER_PAGE * page
        const start = end - COUNT_PER_PAGE
        const data = posts.posts.slice(start, end)

        if (data.length < 1) {
            setHasMore(false)
            return
        }

        setList([...item, ...data])
    }

    const items = (
        <div className={styles.articleList}>
            {
                item.map(post => (
                    ...(略)...
                ))
            }
        </div>
    )

    return (
        <Layout>
            <InfiniteScroll
                loadMore={loadMore}
                hasMore={hasMore}
                loader={loader}
            >
                {items}
            </InfiniteScroll>
        </Layout>
    )
}
export default Category

個別記事ページ

個別記事ページは全記事の一覧が入った JSON データを取得してきて、getStaticPaths でパスを生成し、そこから取得した、記事固有の ID に基づいて、別に生成した記事個別の JSON データを getStaticProps するという手法で実装しました。

1点だけ、記事内には Twitter のツイート埋め込み機能を使ってツイートが入っていることがあります。元々の Blog では、埋め込みツイート用の JavaScript ファイル (widgets.js) は分離していて、記事データ内には入っておらず、各ページ内で読み込んでいたので、同じように実装すればいいかなと思ったんですが、普通に Head 内などで読み込んだだけだと、ページの初回読み込み時はいいんですが、next/link を使用してページ遷移した場合に機能しません。

最初は next/router で、ページ遷移の度に widgets.js を追加し直す作戦を考えたんですけども、あまりスマートじゃないし、何か方法ないかなと思って Twitter のドキュメントを読んでたら、twttr.widgets.load() なんて便利な関数があるじゃないですか。

ということで、next/router と組み合わせて下記のように実装しました。

import React, { useEffect } from 'react'
import Router from 'next/router'

const Slug = (post) => {

    useEffect(() => {
        const s = document.createElement('script')
        s.setAttribute('src', 'https://platform.twitter.com/widgets.js')
        s.setAttribute('defer', 'true')
        document.head.appendChild(s)

        const widgetReload = () => {
            twttr.widgets.load()
        }
        Router.events.on('routeChangeComplete', widgetReload)
    }, [])

}
export default Slug

全記事一覧ページ

全記事一覧は、カテゴリ別一覧でも使った無限スクロールを使用すれば、普通に全記事一覧の JSON を持ってきて比較的簡単に作れますが、こちらはページ分割の実装をやってみたかったので、60件ごとにページ分割するようにしてみました。

全記事一覧ページのページ分割

仕組み的には全記事の数を調べて、それを 1ページあたりの件数で割れば必要なページ数がわかりますので、それを使って getStaticPaths でパスを生成し、あとは getStaticProps でパスごとに記事を入れていけば OK ということになります。この部分のソースコードは、下記の記事で、アーカイブページのページ分割を実装している部分を参考にさせて頂きました。

ということで、必要な部分だけ抜粋すると下記のような感じです。

import { getAllPosts } from '../../lib/api'

const COUNT_PER_PAGE = 60
const range = (stop) => {
    return Array.from({ length: stop }, (_, i) => i + 1)
}

export const getStaticPaths = async () => {
    const allPosts = await getAllPosts()
    const pages = range(Math.ceil(allPosts.entry.length / COUNT_PER_PAGE))
    const paths = pages.map((page) => ({
        params: { page: `${page}` }
    }))

    return {
        paths: paths,
        fallback: false
    }
}

export const getStaticProps = async ({ params }) => {
    const page = parseInt(params.page, 10)
    const end = COUNT_PER_PAGE * page
    const start = end - COUNT_PER_PAGE
    const allPosts = await getAllPosts()

    return {
        props: {
            posts: allPosts.entry.slice(start, end),
            page: page,
            total: allPosts.entry.length,
            perPage: COUNT_PER_PAGE,
        }
    }
}

前述したとおり、まずは全記事の一覧が入った JSON を取得し、記事の総数を 1ページあたりの表示件数で割ってページ数を算出し、そこまでの整数を配列として保存、パスを生成します。その後、getStaticProps で 1ページに必要な記事データを取得し、ページを生成していけば完成です。

ページ送りリンクの部分は、下記のように実装しました。

import Pager from '../../components/Pager'

const Archive = (posts) => {

    const page = posts.page
    const total = posts.total
    const perPage = posts.perPage

    return (
        <Pager
            page={page}
            total={total}
            perPage={perPage}
            href="/archive/[page]"
            asCallback={(page) => `/archive/${page}`}
        />
    )
}
export default Archive

Pager.js の中身は下記のような感じ。参考にさせて頂いた記事のほぼ丸パクリです (すみませんすみません)。

import Link from "next/link"
import styles from '../styles/pager.module.css'

const Pager = (props) => {

    const { total, page, perPage, href, asCallback } = props
    const prevPage = page > 1 ? page - 1 : null
    let nextPage = null

    if (page < Math.ceil(total / perPage)) {
        nextPage = page + 1
    }

    return (
        <ul className={styles.pager}>
            {prevPage ? (
                <li className={styles.pagerItem}>
                    <Link href={href} as={asCallback(prevPage)}>
                        <a>{prevPage}</a>
                    </Link>
                </li>
            ) : ``}
            <li className={styles.pagerItem}><span>{page}</span></li>
            {nextPage ? (
                <li className={styles.pagerItem}>
                    <Link href={href} as={asCallback(nextPage)}>
                        <a>{nextPage}</a>
                    </Link>
                </li>
            ) : ``}
        </ul>
    )
}
export default Pager

サイト内検索ページ

サイト内検索に関しては、Google カスタム検索をそのまま使用しています。

Google カスタム検索にはいくつかのパターンがあると思いますが、私はリニューアル前の状態から 「検索結果のみ」 を表示する方法を選択していましたので、JavaScript ファイルを 1つと、検索結果を表示したい場所に gcse-searchresults-only という class 名が付いた空の dev 要素を記述するだけで終わります。Next.js でも同じ事をするだけで普通に稼働しました。

import React, { useEffect } from 'react'

const Search = () => {

    useEffect(() => {
        const s = document.createElement('script')
        s.setAttribute('src', 'https://cse.google.com/cse.js?cx=[YOUR_SEARCH_ID]')
        s.setAttribute('async', 'true')
        document.head.appendChild(s)
    }, [])

    return (
        <Layout>
            <div className="gcse-searchresults-only"></div>
        </Layout>
    )
}
export default Search

最初は JSON データから自前で検索する仕組みを考えたんですけども、まぁ面倒くさいし、精度的な問題もあるので Google さんを利用する形で終わらせています。

ページ遷移時のローディングバー表示

その他、装飾的な意味でですが、ページ遷移時にヘッダ部分にローディングバーを表示するようにしてみました。NProgress を使用することで簡単に実装できます。

npm install nprogress --save

_app.js 内で下記のように読み込みました。

import Head from 'next/head'
import { useEffect } from 'react'
import Router from 'next/router'
import NProgress from 'nprogress'
import 'nprogress/nprogress.css'
import '../styles/globals.css'

NProgress.configure({ showSpinner: false })

function MyApp({ Component, pageProps }) {

  useEffect(() => {
    Router.events.on('routeChangeStart', () => {
      NProgress.start()
    })
    Router.events.on('routeChangeComplete', () => {
      NProgress.done()
    })
    Router.events.on('routeChangeError', () => {
      NProgress.done()
    })
  }, [])

  return (
    <>
      <Head>
          ...(略)...
      </Head>
      <Component {...pageProps} />
    </>
  )
}
export default MyApp

ということで、年末年始のお休み中に無事 Blog のリニューアルを完了することができました。

あとは、まだ面倒くさくて対応していないんですが、例えば GitHub Actions を利用して、記事を公開したらビルド→デプロイするところまで自動化したりすると実際の運用では楽になりますね。今後試してみたいと思います。

ちなみに新規で立ち上げた Web サイトなら、Vercel にデプロイするとかでもっと楽にできるので、新規で Blog 作るなら今回の方法は積極的に採用したいかもしれません。

記事をここまで御覧頂きありがとうございます。
この記事が気に入ったらサポートしてみませんか?
サーバ代の足しにさせて頂きます。
Social Share