Notion API + Next.jsでヘッドレスCMSなブログを作る

Published
2023-04-29
Tags

なぜ作ったか?

今の御時世、技術記事を書くならZennやQiitaなどがあるわけですが、やっぱり自分の好きなエディターであるNotionで文章を書きたいんですよね。

そこで、Notion APIを使えば最近流行りのヘッドレスCMSの代わりになって、ブログ自体は自分で作ればいいのでは?という考えになったのがきっかけです。

このブログのリポジトリ

このブログは、実際にNotion APIとNext.jsを使って作成したブログになります。

ソースコードだけ見たいという方は、パブリックなリポジトリですのでこちらをご覧下さい。

Setup Next.js with TypeScript & TailwindCSS

今回は、Next.js v13.1.2, React v18.2.0, TypeScript v4.9.4を使用しています。

create-next-app でセットアップします。

$ npx create-next-app@latest --ts notion-headless-cms-nextjs-blog

次に以下の公式ドキュメントを参考にTailwindCSSのセットアップを行う。

具体的な差分はこちらのPRのコミット


その後は、 create-next-app が自動作成した不要なファイルや実装を削除

具体的な差分はこちら

UI実装の詳細は、この記事の本題ではないのでひとまず、基本的なUI実装までしたのが以下のPR

次にNotion APIを使って、Notionで書いた記事をNext.js側で取得して表示する

Notion APIで記事を取得する

ここでは、@notion-sdk/clientをインストールして、Notion APIを使用して記事を取得する方法について説明します。

※ 使用するバージョンは、最新バージョンではなく 1.0.4


まずは、記事取得の通信処理を utlils/notion.ts として作成

import { Client } from '@notionhq/client'

const notion = new Client({ auth: process.env.NOTION_KEY as string })
const DATABASE_ID = process.env.NOTION_DATABASE_ID as string

export const fetchPages = async () => {
  return await notion.databases.query({
    database_id: DATABASE_ID
  })
}

次に、Next.js の getStaticProps で記事を取得

export const getStaticProps: GetStaticProps = async () => {
  const { results } = await fetchPages()
  return {
    props: {
      pages: results ? results : []
    },
    revalidate: 10 // ISRを実行するために必要な設定。指定した秒数が経過したらfetchが走り、記事に差分があれば再ビルド
  }
}

あとは、コンポーネントに組み込んで画面として表示する

実際の差分はこちら

Notion APIで取得したデータをReactコンポーネントとしてレンダリングする

APIから受け取ったレスポンスは、あくまでただのJSONなので、Reactコンポーネントとして最終的に画面に表示したい。

今回は、https://github.com/takux/notion-block-renderer というライブラリを使う。

このライブラリを使うことで、Notionで書いた記事内のテキストやコードシンタックス、画像などをのブロック(Notionでは文中の1要素をブロックという単位で定義している)をReactコンポーネントしてマッピングしてレンダリングすることができる。

実際にこのライブラリを使ってみた実装のコミットはこちら

ただ、残念ながらテーブルやURLプレビューなど対応していないNotionブロック形式もあるので注意

次は、Notionから記事を取得する際によりパフォーマンス効率の良いNext.jsのSSRとISR使ったデータフェッチについて書いておきたい。

Next.js の SSR と ISR

まずおさらいで、Next.jsにおける SSR とISR について確認する。


Next.jsでは、サーバーサイドレンダリング(SSR)とインクリメンタルスタティックレンダリング(ISR)を getStaticPropsgetServerSidePropsを使って実現できる。

サーバーサイドレンダリング (SSR)

SSRは、サーバー上でページを事前にレンダリングし、クライアントにHTMLを送信する手法です。Next.jsでは、getServerSidePropsを使ってSSRを実現できる。

getServerSideProps

getServerSidePropsは、サーバー上でページをレンダリングする際に実行される非同期関数です。この関数は、APIからデータを取得してpropsとして返すことができる。

export async function getServerSideProps(context) {
  const data = await fetchData(context.params.id);
  return {
    props: {
      data,
    },
  };
}


インクリメンタルスタティックレンダリング (ISR)

ISRは、ページを静的に生成する手法で、ページがリクエストされる度にサーバー上で再レンダリングすることで最新のデータを提供し、Next.jsでは、getStaticPropsを使ってISRを実現できる。

getStaticProps

getStaticPropsは、ビルド時に実行される非同期関数で、静的なプロップスを生成します。この関数は、APIからデータを取得してプロップスとして返すことができます。さらに、revalidateオプションを設定することで、ISRを実現できる。

export async function getStaticProps(context) {
  const data = await fetchData(context.params.id);
  return {
    props: {
      data,
    },
    revalidate: 60, // 60秒ごとに再レンダリング
  };
}


SSRとISRの使い分け、メリット・デメリット

サーバーサイドレンダリング (SSR)

使い分け

  • SSRは、リアルタイムのデータが必要なページやSEOが重要なページに適しています。これにより、常に最新のデータを表示することができる。

メリット

  • 常に最新のデータが表示されるため、リアルタイム性が高い。
  • クライアントサイドでのレンダリングが不要なため、SEO対策に有効。

デメリット

  • ページがリクエストされるたびにサーバーでレンダリングが行われるため、サーバーへの負荷が高くなる可能性がある。
  • ページ表示速度がサーバーの処理速度に依存する。


インクリメンタルスタティックレンダリング (ISR)

使い分け

  • ISRは、データの更新頻度が低いページや、高速なページ表示が求められるページに適しています。あらかじめ静的ファイルを生成し、必要に応じて更新することで、パフォーマンスが向上する。


メリット

  • 事前に静的ファイルを生成するため、ページ表示速度が高速。
  • サーバーへの負荷が低い。
  • revalidateオプションにより、定期的にページを更新することができる。


デメリット

  • データのリアルタイム性が低い場合がある。
  • ビルド時に全てのページを生成する必要があるため、ビルド時間が長くなる可能性がある。


Notion APIを ISRでデータフェッチする上で気をつけるべきこと

実は、Notion API経由で記事のデータを取得する際に、記事の中に画像が含まれている場合に注意が必要な点がある。

何かというと、Notionでアップロードした画像は有効期限があり、ISRで取得した場合は記事の内容に変更がない限りはキャッシュされビルドがされないので有効期限が古いままの記事内画像を取得するため、結果的に画像が表示されないという問題がある。


具体的には、以下のようなお話


そのため、記事内画像の有効期限切れを回避するためには、リクエストのたびにビルドを行う SSR でデータフェッチする必要があり、その分パフォーマンスが下がるのでトレードオフである。

ひとまず、自分はSSRする方向にしたのがこちらのコミット

ただ一度、ISR による高速なデータフェッチを体験すると、SSRはとてもツライ….


長文記事を取得する場合は、pagination対応が必須

無事に SSR あるいは ISR で、Notion APIから記事を取得できたところで、もう1つ問題がある。それは長文記事の場合、1度のリクエストでは全内容を取得できないという問題である。

これは Notion API の仕様で、1度のリクエストで取得できるブロックが、デフォルトで最大100件しか取得できないため、ページネーションAPIを使用することで、全てのデータを取得できる。


実装としては、以下のようにページネーションのパラメーターを使用して複数回リクエストを走らせる。

import { Client } from '@notionhq/client'
const notion = new Client({ auth: process.env.NOTION_KEY as string })
const DATABASE_ID = process.env.NOTION_DATABASE_ID as string

export const fetchBlocksByPageId = async (pageId: string) => {
  const data = []
  let cursor = undefined

  while (true) {
    const { results, next_cursor }: any = await notion.blocks.children.list({
      block_id: pageId,
      start_cursor: cursor
    })

    data.push(...results)
    if (!next_cursor) break
    cursor = next_cursor
  }

  return { results: data }
}

対応コミット

まとめ

ざっくり、NotionをヘッドレスCMSとして使用してブログを作る検証を行ってみた。

今後は、このブログで知見や雑メモをためていきたいと思う。