Next.js + esa.io + VercelでJAMStackな爆速ブログを構築する
追記: 本記事がesa.ioの公式Twitterに取り上げられました!
esaに書いた記事をNext.jsで公開する
Next.jsのバージョン9.3から、ビルド時に外部ソースからデータを取得するgetStaticPropsというAPIが公開されました。
ブログは静的なコンテンツです。ブログの内容はユーザーに応じて動的に変わるということはありません。そして、getStaticPropsは静的なページを構築するために最適なAPIです。
そこで、esaにmarkdownで書いた記事をNext.jsで表示するサイトを構築しました。
実際にサイトにアクセスして記事を開いてみてください。爆速で遷移するのが体験できます。Lighthouseの成績もバツグンです。(blog-starterをベースに利用したため、コンテンツはそのレポジトリの内容を踏襲しています)
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
を開きましょう。
そして、「Generate new token」をクリックして、Personal access tokensを作成します。
tokenに名前をつけましょう。また、今回は読み取りだけなので、read権限のみを付与します。
このキーはAPIから記事を取得する際に利用します。一度しか表示されないので、コピーして別の箇所に保存します。
記事を作成する
esaで記事を作成しましょう。いつものように記事書くことに加え、記事の冒頭にslugや画像など、メタ情報を記述していきます。
(追記: 将来的にはメタ情報を追加できるようになるそうです。機能追加を期待して待ちましょう😊)
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].js
でgetStaticPropsを利用します。ビルド時にブログの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
内で呼び出している関数getAllPosts
とgetPostBySlug
は下記のように実装しています。
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-routing
、hello-world
、preview
の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を使ってデプロイする
環境変数の設定が完了したら、プロジェクトルートで下記のコマンドを実行します。これがデプロイコマンドです。
$ 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はサイトのキャプチャも表示してくれるので、トップページはわざわざ毎回アクセスする手間が省けます。このような配慮も嬉しいですね。
Lighthouseのスコアも完璧。爆速ブログの完成です!
触ってみてわかった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 🎉