Next.js + Notion API でブログを作ってみた

2025/2/12025/2/16 に更新)

Next.js でブログを作ってみました。 いま見ていただいているこのブログがそれです。 CMS として Notion を使っています。

この記事では備忘録もかねて、その過程をざっくり載せています。

ちなみに、リポジトリは公開しています。拙いコードですが、同じ要件で作ろうとされているかたはご参考までに。

Next.js と Notion でブログを作るまで

なるべく簡単に、そして無料で制作・運用することを目指しました。ホスティングサービスは Vercel(Hobby プラン)を利用しています。

CMS としての Notion

Notion は、よく意識高い系が使っているメモサービスです。僕も意識が高いので使っています。その優れた UI と多機能さから、今ではドキュメントの作成や管理に利用している企業も少なくないかもしれません。

Notion の特徴はたくさんあるのですが、そのうちいくつか抜粋すると…

  • 多機能なエディタでリッチな文章が書ける
  • 複数のメモ(Notion では「ページ」と呼ぶ)を「データベース」として整理することができる
  • ページやデータベースは API で取得できる
    • API を簡単に叩くための Next.js パッケージを公式が用意してくれている
    • いまのところ無料

これはもう(ヘッドレス) CMS として利用できるやん! …ということで、検索してみると多くのかたが Notion を採用されています。

ほかの選択肢
  • Micro CMS などのほかのヘッドレス CMS を使う
  • だいたい有料です。

  • 記事を Markdown で書いて、リポジトリの一部として Github で管理する
  • catnose さんがご自身の昔のブログでやられていた方法です。編集には PC が必須となり、外出中に「書こう!」と思っても書けないのがすこし心配でした。

記事の一覧ページを作る

まずひとつのページを記事とみなして、それをまとめるデータベースを作ります。記事にはタイトルのほかに、タグ、公開日、公開フラグなど、自由にプロパティを設定できます。

データベースの例
データベースの例

そのあと、Notion 公式のパッケージを使用して、データベースにあるすべての記事を取得します。

  • notion-client
  • Notion 公式が提供している、Notion API を叩くためのパッケージ。

import { Client } from "@notionhq/client";

const notion = new Client({
    auth: "Notion にアクセスするためのトークン"
});

const response = await notion.databases.query({
    database_id: "取得したいデータベースの ID"
});

console.log(response.results); // <- 記事がぜんぶ入っている
response.results の中身
response.results の中身

取得したものにはタイトル、タグ、公開日など、データベースに設定したプロパティが入っているので、あとは順番に表示するだけです。なんて簡単なんだ!

記事の詳細ページを作る

記事の本文は、Notion 公式のパッケージを使っても取得できるのですが、今回は別のパッケージを使って取得します。

  • notion-to-md
  • 取得するページを指定すると、内部で API を実行して本文を取得したのち、Markdown 形式の文字列に変換してくれるライブラリ。

  • zenn-markdown-html
  • Markdown 形式の文字列を HTML に変換してくれるライブラリ。
    名前に「zenn」がついている通り、
    Zenn が公開しているもので、変換後の HTML は Zenn のスタイルで表示されます。
    CSS を頑張って設定しなくても美しいスタイルで表示されるので、おすすめです。

Notion 公式パッケージを使わない理由
  • 文章、見出し、画像などの要素( Notion では「ブロック」と呼ぶ)ごとにオブジェクトでやってくるため、それを HTML に変換する必要がある。
  • 1 度に 100 ブロックまでしか取得できず、続く場合は別途取得する必要がある。
  • ネストになっているブロックは、別途取得する必要がある。

「notion-to-md」は、上の 3 つをすべてやってくれます。

Notion 公式のライブラリで取得した本文
Notion 公式のライブラリで取得した本文
要素ごとにオブジェクトで入っており、すべてを手作業で変換する必要があります…
要素ごとにオブジェクトで入っており、すべてを手作業で変換する必要があります…

import { NotionToMarkdown } from "notion-to-md";
import zennMarkdownHtml from 'zenn-markdown-html';

const n2m = new NotionToMarkdown({ notionClient: notion });

// Notion -> Markdown
const markdown = n2m.toMarkdownString(await n2m.pageToMarkdown("取得したいページの ID"));

// Markdown -> HTML
const html = zennMarkdownHtml(markdown.parent, {
    embedOrigin: "https://embed.zenn.studio",
});

あとはこちらに書かれているように、変換した HTML とともに、別途 Zenn が提供する CSS と JavaScript を差し込んであげると、きれいに表示されるはずです。

  • zenn-content-css
  • 「zenn-markdown-html」で変換された HTML に対して適用できる CSS。

import 'zenn-content-css';
import Script from "next/script";

export default async function Page() {

  const body = "..."; // 変換した HTML
	
  return (
    <>
      <Script src="https://embed.zenn.studio/js/listen-embed-event.js" strategy="beforeInteractive" />
      <div className="znc" dangerouslySetInnerHTML={{ __html: body }} />
    </>
  );
}

困ったこと

画像が表示されない問題

このブログは現在 ISR にして、1 時間ごとにページが再生成されるようにしているのですが、この場合だと Notion からやってくる画像が表示されないことがあります。 なぜなら、Notion から送られてくる画像 URL には、1 時間の有効期限があるからです。実際に☝️これまでの画像、表示されていましたでしょうか…?

The URL is valid for one hour.(URL は 1 時間有効です。)
Notion API の公式ドキュメント
ISR とは?

Next.js はあくまでもページを生成するコードを書くだけなので、どこかのタイミングで生成する必要があります。そのタイミングは 4 つあります。

  • SSG(Static Site Generation)
  • あらかじめすべてのページを生成する。

  • ISR(Incremental Static Regeneration)
  • あらかじめすべてのページを生成するが、一定期間で再生成する。

  • SSR(Server Side Rendering)
  • アクセス時にサーバ側で生成する。

  • CSR(Client Side Rendering)
  • アクセス時にクライアント側で生成する。

それぞれ次のような特徴があります。

SSGISRSSRCSR
表示速度⚪︎⚪︎×
リアルタイム性×⚪︎⚪︎

SSG と ISR はあらかじめページを生成しておくので、SSR や CSR と比較して情報が古くなりがちですが、表示速度が速いのが特徴です。特にブログの場合は、記事を更新したりするため、前者 2 つのなかでも ISR が適しています。

ちなみに、それぞれの page.tsx に次の 2 つを記載すると ISR になります。

  • 変数 revalidate
  • 再生成する間隔を指定します(単位は秒)。

    // 1 時間ごとに再生成する
    export const revalidate = 3600;
  • 関数 generateStaticParams
  • URL からパラメータを受けつけるページの場合のみ、定義する必要があります。
    生成時に呼び出され、この関数で返したパラメータぶん、ページを生成します。

    // ページの URL が "/post/[記事の ID]" の場合
    export const generateStaticParams = async () => {
    
        // すべての記事
        const postList = await getAllPost();
    
        // すべての記事の ID を配列で返す
        return postList.map((post) => ({
            id: post.id,
        }));
    };

「え、ISR で 1 時間ごとに再生成してるので問題ないのでは?」と思われるかもしれませんが、ISR は「 1 時間ぴったりに再生成する」わけではなく、「 1 時間を経過したあとにアクセスがあったときに初めて再生成」し、「生成が完了するまでは古いページを返す」のです。生成には分単位でかかることもあるため、少なくとも再生成がトリガーされるアクセスについては、古いページが返されます。

つまり、極端な話、「アクセスされる頻度が 1 時間を超えるようなページ(まさにこのブログ)の場合、画像が永遠に表示されない」のです。そんなバナナナチ。

解決方法としては、次のものが考えられるようです。

再生成をトリガーする API を作り、定期的に叩く

NextApiRespnserevalidate を実行すると、引数に渡したパスのページが再生成されます。適当に API のエンドポイントを作り、それを定期的に叩くようにすれば、画像が表示されないアクセスをなくすことができます。

export default async function handler(req: NextApiRequest, res: NextApiResponse) {
  ... 
  await res.revalidate('/'); // 👈 指定したパスのページが再生成される
  ...
}

クライアント側で取得する

Notion 公式のライブラリで本文を取得すると、画像ブロックのオブジェクトに有効期限が載っています。クライアント側でこの有効期限が切れているかを判定して、切れている場合は別途その画像だけを取得します。

Notion 公式のライブラリで取得した画像ブロックのオブジェクト
Notion 公式のライブラリで取得した画像ブロックのオブジェクト

最後に

こちらのブログですが、執筆時点(2025年2月1日)で必要最低限の仕組みしかありません。画像が表示されない問題も解決していません。時間があるときにどんどんアップデートしていきたいです。記事の執筆もね……!

  • HIGH
    • 画像が表示されない問題
  • MIDDLE
    • 記事のサムネイル(OG 画像)
    • 目次
    • About ページ
    • サイトマップ
    • 文字の色/背景色への対応
    • SEO 対策
  • LOW
    • いいね
    • コメント
    • RSS

実はこのブログ、しずかなインターネットでさえ続かなかった僕が「苦労して作れば、愛着が出て続くだろ…」と思って作ったものです。苦労して作ったのだから、もちろん続きますよね。今後をお楽しみに!

謝辞

ブログの制作にあたってご支援いただいた以下の方々に感謝申し上げます。
(50音順・敬称略)

  • Github Copilot