Next.js + esa.io + VercelでJAMStackな爆速ブログを構築する

@Panda_Program

追記: 本記事がesa.ioの公式Twitterに取り上げられました!

esaに書いた記事をNext.jsで公開する

Next.jsのバージョン9.3から、ビルド時に外部ソースからデータを取得するgetStaticPropsというAPIが公開されました。

ブログは静的なコンテンツです。ブログの内容はユーザーに応じて動的に変わるということはありません。そして、getStaticPropsは静的なページを構築するために最適なAPIです。

そこで、esaにmarkdownで書いた記事をNext.jsで表示するサイトを構築しました。

実際にサイトにアクセスして記事を開いてみてください。爆速で遷移するのが体験できます。Lighthouseの成績もバツグンです。(blog-starterをベースに利用したため、コンテンツはそのレポジトリの内容を踏襲しています)

Lighthouseで測定した結果。スコアはperformanceの98以外、全て100

デモサイトのコードはGitHubにアップしています。

Next.jsのblog starterを利用する

準備として、Next.jsの公式が用意しているblog-starterを利用しました。

blog starterではmarkdownファイルをgray-matterでHTMLに変換しています。今回は、markdownのソースをローカルファイルではなくesaのAPIのレスポンスに変更するように書き換えます。

esaの設定をする

APIキーを作成する

まずはHTTPリクエストでesaの記事を取得するために、APIキーを作成しましょう。

esaにアクセスして、setting > applicationを開きましょう。

esaの管理画面

そして、「Generate new token」をクリックして、Personal access tokensを作成します。

esaの管理画面

tokenに名前をつけましょう。また、今回は読み取りだけなので、read権限のみを付与します。

esaの管理画面

このキーはAPIから記事を取得する際に利用します。一度しか表示されないので、コピーして別の箇所に保存します。

記事を作成する

esaで記事を作成しましょう。いつものように記事書くことに加え、記事の冒頭にslugや画像など、メタ情報を記述していきます。

esaの記事の編集画面

GitHubに記事内容のサンプルを掲載しています。

(追記: 将来的にはメタ情報を追加できるようになるそうです。機能追加を期待して待ちましょう😊)

Next.jsのビルド時にesaから記事を取得する

記事取得のAPIをコールする

esaへのAPIリクエストは以下のように作成できます。先ほど取得したAPIキーとチーム名は環境変数に設定します。

const API_TOKEN = process.env.NEXT_EXAMPLE_CMS_ESA_API_TOKEN
const TEAM = process.env.NEXT_EXAMPLE_CMS_ESA_TEAM
const endpoint = `https://api.esa.io/v1/teams/${TEAM}/posts`

async function fetchAPI(path) {
  const res = await fetch(`${endpoint}${path}`, {
    headers: {'Authorization': `Bearer ${API_TOKEN}`}
  })

  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
}

レイヤードアーキテクチャでは、このfetchAPIがData Access層に相当します。

Next.jsはバージョン9.4から.envファイルに環境変数を書き込むだけで、process.envを通じて値を利用できるようになりました。このため、環境変数は.envを作成して値をそちらに記載します。

NEXT_EXAMPLE_CMS_ESA_API_TOKEN=
NEXT_EXAMPLE_CMS_ESA_TEAM=
NEXT_EXAMPLE_CMS_ESA_CATEGORY=

基本的なリクエストは上記ですが、クエリパラメータで返却される記事を絞ることができます。主に使うクエリは下記です。

|key|value|役割| |--|--|--| |sort|created|並べ替えのキーを作成日にする| |order|desc|降順で並べる| |q|wip:false|shipされた記事を取得する| |q|on:category名|category名に完全一致する記事を取得する|

その他のクエリパラメータはesaの公式サイトで確認できます。

SSGをするためにgetStaticPropsを利用する

SSGとは、Static Site Generationのことです。これは静的なページをビルド時に作成することに特徴があります。

SSRはリクエストを受けてサーバーでコードを実行しますが、SSGのページは静的なファイルのみを返します。このため、レスポンスにかかる時間を短縮できます。簡単にいうと、表示速度が阿部寛のページ並みになるということです。

ページ、pages/posts/[slug].jsgetStaticPropsを利用します。ビルド時にブログのslugが確定しているため、ブログ記事のURLは決まっています。例えば、/posts/hello-worldなど、記事のslug名ごとのパスで記事にアクセスできます。一方、/posts/foo-barなど、slugに存在しないパスを入力すると404が返ってきます。

これを実現するために、getStaticPathsを使って、slugごとにページを作成しましょう。

export async function getStaticProps({params}) {
  // slugをkeyに記事のコンテンツをesaから取得する
  const post = await getPostBySlug(params.slug, [
    'title',
    'date',
    'slug',
    'author',
    'content',
    'ogImage',
    'coverImage',
  ])
  const content = await markdownToHtml(post.content || '')

  return {
    props: {
      post: {
        ...post,
        content,
      },
    },
  }
}

export async function getStaticPaths() {
  // 全ての記事のslugを取得する
  const posts = await getAllPosts(['slug'])

  return {
    paths: posts.map((posts) => ({
      // paramsはgetStaticPropsに渡される
      params: {
        // [slug].jsなので、プロパティはslugにする。
        // もし[id].jsなら、{params: {id: post.slug}} にする
        slug: posts.slug,
      }
    })),
    fallback: false,
  }
}

Data Mapping層を設けて、esaから取得するデータを変換する

getStaticPaths, getStaticProps内で呼び出している関数getAllPostsgetPostBySlugは下記のように実装しています。

export async function getPostBySlug(slug, fields = []) {
  const data = await fetchAllPosts()
  const posts = data.posts.map((post) => mapPost(post, fields))
  return posts.filter((post) => post.slug === slug)[0]
}

export async function getAllPosts(fields = []) {
  const data = await fetchAllPosts()
  return data.posts.map((post) => mapPost(post, fields))
}

設計面の話すると、今回はAPIがデータソースであるため、ブログアプリケーションで必要なデータとは異なるデータが返ってきます。そこで、Reactで使いやすいデータに整形する必要があります。そのデータマッピングをするためにmapPost関数を作成しています。

また、esaの特徴として、記事のIDはincrementalな数字です。このesaの仕様のままでは、記事のアドレスは/posts/1/posts/777になってしまいます。

しかし、記事のアドレスは無機質な数字より英文の記事名の方がSEO対策になります。例えば、このブログでも記事のslugは必ず英語にしています(この記事は/posts/nextjs-with-cms-esa)。そして、このslugはesaのAPIレスポンスには含まれていません。このため、記事のメタデータの中にslugを自分で設定できるようにしています。

このように、データの保存元とデータの利用先で必要なデータの形式が異なることはソフトウェアでは一般的です。そこで、下記のようにData Mapping層を設けています。

ここでは引数fieldsでkeyを指定し、必要な情報だけを取得できます。

function mapPost(post, fields) {
  // gray-matterでmarkdownからメタデータと本文を分離する
  const {data, content} = matter(post.body_md)
  const items = {}

  fields.forEach((field) => {
    switch (field) {
      case 'slug':
        if (typeof data.slug === 'undefined') {
          throw new Error('Slug is not set.')
        }
        items[field] = data.slug
        break
      case 'content':
        items[field] = content
        break
      default:
        if (data[field]) {
          items[field] = data[field]
        }
        break
    }
  })

  return items
}

データをコンポーネントに注入する

propsは上記のgetStaticPropsから渡されます。

export default function Post({post, preview}) {
  const router = useRouter()
  if (!router.isFallback && !post?.slug) {
    return <ErrorPage statusCode={404}/>
  }
  return (
    <Layout preview={preview}>
      <Container>
        <Header/>
        {router.isFallback ? (
          <PostTitle>Loading…</PostTitle>
        ) : (
          <>
            <article className="mb-32">
              <Head>
                <title>
                  {post.title} | Next.js Blog Example with {CMS_NAME}
                </title>
                <meta property="og:image" content={post.ogImage.url}/>
              </Head>
              <PostHeader
                title={post.title}
                coverImage={post.coverImage}
                date={post.date}
                author={post.author}
              />
              <PostBody content={post.content}/>
            </article>
          </>
        )}
      </Container>
    </Layout>
  )
}

ちなみに、コンポーネントはblog-starterから1行の変更なく使い回すことができました。

データの取得元をmarkdownからesaのAPIに変更しただけなので、「コンポーネントが必要なデータさえ渡せばviewに変更を加えることなく表示内容を替えることができる」というのはviewとロジックが分離しているクリーンなコードである証左ですね。

ローカルでビルドして記事取得を確認する

手元でビルドしてみましょう。

$ npm run build

> [email protected] build /Users/matthew/sample_program/react/next-blog-esa
> next build

info  - Loaded env from .env
Creating an optimized production build

Compiled successfully.

Automatically optimizing pages

Page                                Size     First Load JS
┌ ● /                               1.17 kB        70.2 kB
├   /_app                           288 B            58 kB
├ ○ /404                            2.55 kB        60.5 kB
└ ● /posts/[slug]                   2.03 kB        71.1 kB
    └ css/f6f82ffa2b321e5ac3c5.css  167 B
    ├ /posts/dynamic-routing
    ├ /posts/hello-world
    └ /posts/preview
+ First Load JS shared by all       58 kB
  ├ static/pages/_app.js            288 B
  ├ chunks/commons.ceeeee.js        10.7 kB
  ├ chunks/framework.e84fa6.js      40 kB
  ├ runtime/main.2d0e0e.js          6.28 kB
  ├ runtime/webpack.c21266.js       746 B
  └ css/d7c40193fbab5fdba323.css    2.46 kB

λ  (Server)  server-side renders at runtime (uses getInitialProps or getServerSideProps)
○  (Static)  automatically rendered as static HTML (uses no initial props)
●  (SSG)     automatically generated as static HTML + JSON (uses getStaticProps)

下記の箇所が注目ポイントです。

└ ● /posts/[slug]                   2.03 kB        71.1 kB
    └ css/f6f82ffa2b321e5ac3c5.css  167 B
    ├ /posts/dynamic-routing
    ├ /posts/hello-world
    └ /posts/preview

は、SSGで作成されたページであることを表します。ファイルは/pages/[slug].jsしか用意していないのに、getStaticPathsで取得したslugに応じてdynamic-routinghello-worldpreviewの3つのページが作成されています。これがSSGの特徴です。

getStaticPaths返り値のfallbackをfalseにしているので、ここに存在しないパス/posts/foo-barなどはコンテンツが存在せず、404になります。

VercelでデプロイしてJAMStackな構成にする

Next.jsを手軽にデプロイするならVercelがベストです。Vercelは、Next.jsを開発しているVercel社のサービスです。無料でサーバレスなアプリをデプロイできることが特徴です。

ちなみに、サービスとしてのVercelの旧名はNowで、会社としてのVercelの旧名はZEITでした。$21M、つまり2,100万ドル(約22億円)を調達したタイミングでサービスと社名が一致するように改名されました。

コマンドをインストールする

まずはVercelをインストールします。

$ npm i --global vercel@latest

環境変数を設定する

マイページから環境変数を設定します。

vercelのsetting画面

環境変数は、環境ごとに設定できます。

vercelを使ってデプロイする

環境変数の設定が完了したら、プロジェクトルートで下記のコマンドを実行します。これがデプロイコマンドです。

$ vercel
Vercel CLI 19.0.1
🔍  Inspect: https://vercel.com/panda-program/next-blog-esa/hqm0kdiw1 [6s]
✅  Preview: https://next-blog-esa.panda-program.now.sh [copied to clipboard] [44s]
📝  To deploy to production (next-blog-esa.now.sh), run `now --prod`

たった1コマンドでデプロイできました。https://next-blog-esa.panda-program.now.shはPreview用のURLです。Vercelのデプロイ方針は、develop, staging, productionではなく、Develop, Preview, Ship(Production)です。このPreview用のURLはデプロイするたびに作成されます。

本番用にデプロイするには--prodオプションを追加します。

$ vercel --prod
Vercel CLI 19.0.1
🔍  Inspect: https://vercel.com/panda-program/next-blog-esa/d6363ukyc [7s]
✅  Production: https://next-blog-esa.now.sh/ [copied to clipboard] [42s]

コマンドの結果に表示されているように、プロダクションのURLはhttps://next-blog-esa.now.sh/です。

ビルドのログを確認する

上記のInspectのURLにアクセスすると、deploy単位で状態を把握できます。

vercelのinspectページ

ビルドのログは画面から見ることができます。

vercelのビルドのログ表示画面

以上でデプロイは完了です!

デプロイに成功すると、Vercelはサイトのキャプチャも表示してくれるので、トップページはわざわざ毎回アクセスする手間が省けます。このような配慮も嬉しいですね。

vercelの管理画面でサイトのプレビューが表示されている

Lighthouseのスコアも完璧。爆速ブログの完成です!

Lighthouseで測定した結果。スコアはperformanceの98以外、全て100

触ってみてわかったCMSとしてesaを使うメリットとデメリット

実際にesaを使ってブログを構築してみました。メリットとデメリットを比べてみましょう。

esaをCMSとするメリット

まず、メリットは以下のようなものです。

・内容がリアルタイムで反映される
・画像準備が楽
・非エンジニアでも記事を入稿できる

esaのエディタはmarkdownをリアルタイムでパースしてくれます。Qiitaのようにmarkdownの変換結果がすぐにわかります。また、画像はドラッグ&ドロップでesaのS3アップロードできます。Gatsbyでは自分でGitレポジトリに画像を配置する必要があるので、手間が省けるのは便利です。

さらに、もし会社でブログを作るなら、markdownを書くために非エンジニアがレポジトリをcloneしてcommitしてpushするのは非現実的です。CUIではなくてもGUIで、GitHubのサイト上で直接ファイルの作成・編集はできます。ただ、そもそも入稿者が増えるたびにGitHubアカウントを作成して、権限を設定するのは煩雑です。

その点、会社で全員がesaで記事をread/writeできるなら、入稿者からは慣れたインターフェースで入稿できるのでとても楽です。

esaをCMSにする時のネックになること

デメリットは以下のことが挙げられるかと思います。

・メタ情報を自分で追加する必要がある
・previewモードが未実装
・3ヶ月目から有料になる

esaのレスポンスは以下のようなものです。ブログとして公開するために欲しいdescriptionやアイキャッチ用のimageといったメタ情報が存在しないのです。

{
    "posts": [
        {
            "number": 9,
            "name": "sample post",
            "full_name": "sample post",
            "wip": false,
            "body_md": "body",
            "body_html": "<p>body</p>",
            "created_at": "2020-05-23T19:43:05+09:00",
            "message": "Create post.",
            "kind": "stock",
            "comments_count": 0,
            "tasks_count": 0,
            "done_tasks_count": 0,
            "url": "https://next.esa.io/posts/9",
            "updated_at": "2020-05-23T19:43:05+09:00",
            "tags": [],
            "category": "blog/nextjs",
            "revision_number": 1,
            "created_by": {
                "name": "パンダ",
                "screen_name": "panda",
                "icon": "https://img.esa.io/uploads/production/users/44031/icon/thumb_m_a793b9b0f4e39c59f914e25ba447f485.jpg"
            },
            "updated_by": {
                "name": "パンダ",
                "screen_name": "panda",
                "icon": "https://img.esa.io/uploads/production/users/44031/icon/thumb_m_a793b9b0f4e39c59f914e25ba447f485.jpg"
            },
            "stargazers_count": 0,
            "watchers_count": 1,
            "star": false,
            "watch": true,
            "sharing_urls": null
        }
    ],
    "prev_page": null,
    "next_page": null,
    "total_count": 3,
    "page": 1,
    "per_page": 20,
    "max_per_page": 100
}

そもそもesaの記事にはdescriptionなどは存在しないからなのですが、ブログとして公開するためには必要な要素です。

そのため、メタ情報をesaの記事に記述する必要があります。また、Next.jsの9.4から実装されたPreviewモードは、esaにAPIがないため利用できません(ただ、previewAPIがなくともesaのエディタのpreviewで十分だとは思っています)。

最後に、esaは無料のサービスではないので、2ヶ月の無料期間が終われば、以降は月500円の課金が必要になります。便利なサービスに対価を払うことは、そのサービスが存続するためには絶対に必要なことです。一方、徹底的に無料でブログを作りたい方にはその点がネックになるでしょう。

まとめ

いかがでしたでしょうか。Next.jsとesaでJAMStackな爆速ブログを作る方法を紹介しました。

Gatsbyではすでにesaをデータソースとするプラグインがありますが、Next.jsでは見当たらなかったので自分で作ってみました。

ユースケースとしては、「企業でesaを情報共有ツールとして使っている。そして、非エンジニアも入稿したい」という時にesaをCMSとして使うのがベストだと思います。

esaのカテゴリを指定して記事を取得できるので、例えばブログ記事は/public/blogに書くというようにブログ専用のカテゴリを作って運用すると良いでしょう。

会社で使うのではなくても、esaを個人で利用している人は少なくないです。一人でも多くの方がブログを公開して、知識を共有することでプログラミング界隈が盛り上がることを願っています。

なお、トップの画像はVercel社のOpen Graph Image as a Serviceというサービスを利用して作成しています。

Happy Coding 🎉

パンダのイラスト
パンダ

記事が面白いと思ったらツイートやはてブをお願いします!皆さんの感想が執筆のモチベーションになります。最後まで読んでくれてありがとう。

  • Share on Hatena
  • Share on Twitter
  • Share on Line
  • Copy to clipboard