Notion APIとNext.jsで日記サイトを作った

## TL;DR

- Notion APIとNext.jsを使って、Notionに書いた日記をWebサイトにレンダリングした
- NotionをヘッドレスCMSとして活用可能
-> 各デバイスのアプリから投稿/編集ができて便利
- ただしAPIの機能がまだ十分でない部分がある

## 作ったもの

https://diary.unronritaro.net/

https://github.com/yuki-oshima-revenant/notion-blog

## 背景

最近Notionを使い始め、その便利さとUXの良さに感動していました。また同時に友達が日記を始めているのを見て、自分もやりたくなっていました。

そんな折にこちらの記事を拝読し、NotionのUIを利用して記事を投稿できる日記が作れたら便利だろうと考えました。

https://zenn.dev/5t111111/articles/80934cdda55328

いわゆるJAMStack構成になるので、静的サイトレンダリングができるフレームワークが適しています。Reactが得意なのでNext.jsを使うことにしました。

## Notion API

### 準備

まずはNotion APIの使い方からご紹介します。

https://developers.notion.com/

ブラウザでNotionにログインした状態で[My integrationsページ](https://www.notion.so/my-integrations)からAPIのintegration(クライアント)を作成します。名前とそのクライアントが利用するワークスペースを選択すればOKです。

すると画面上でAPIトークンが得られるのでコピーしておきましょう。

次にNotionのアプリ上で先ほど作ったクライアントにNotionのページ/データベースへのアクセス権限を与えます。方法はシンプルで、対象のページ/データベースの上部`Share`メニュー > `Invite` > `Select an integration`で作成したクライアントを選択するだけです[^1]。

[^1]: 現在は`Can Edit`の権限しか付与できないようです。

これでNotion APIからページ/データベースの内容を取得する準備が整いました。

### 日記を書く

日記はデータベースの形で整理しておくとNotionからAPIで取得する際に便利です。

以下のようにテーブル形式のデータベースを作成します。

![database.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/458395/d99af741-f172-9ddb-820e-958c992e1592.png)

APIからデータを取得した際に、`published`列にチェックが入っている行のみをレンダリングします。また`date`列は日記の日付インデックスをつけるためのものです。

こちらのデータベースにintegrationのアクセス権限を与えます。また`Copy link`で`https://www.notion.so/{データベースID}?v=xxx`といったURLがコピーされます。このURLからデータベースIDを取り出しておきましょう。APIでデータを取得する際に必要になります。

各行に記事を追加していきます。筆者はタイミングによってPCとモバイルのアプリを使い分けて書いています。リアルタイムに同期されるためデバイス間のやりとりがスムーズで便利です。

![post3.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/458395/de9a99b6-ce43-c71a-bda8-e07deb796fe1.png)

この記事が以下のようにレンダリングされます。

https://diary.unronritaro.net/post/20210724

### APIで記事を取得

https://developers.notion.com/reference/intro

node.jsのSDKがあるのでそれを利用しました[^2]。

[^2]: TypeScriptで書かれていますが、APIレスポンスの型定義が間違っている場合があるので注意してください。筆者は[この辺り](https://github.com/yuki-oshima-revenant/notion-blog/blob/149bfab73611c7abca0ad12d1b598bc7048db4c1/lib/util/notion.ts#L98)でやっているように`@ts-ignore`して強引に使っています。

https://github.com/makenotion/notion-sdk-js

Notion APIには`Database`/`Page`/`Block`の三種類のオブジェクトがあります。

各リソースの取得方法と取得内容は以下の記事を参考に実装していきました。

https://zenn.dev/5t111111/articles/80934cdda55328

Notionに書いた日記の親ページのテーブルの内容は`notion.databases.query()`で取得できます。`.env`ファイルを使って環境変数にAPIトークン(`NOTION_SECRET`)と先ほどURLから取得したデータベースID(`NOTION_DATABASE_ID`)を入れておいて以下のように実装しました。

```ts
import { Client } from '@notionhq/client';

const notion = new Client({
    auth: process.env.NOTION_SECRET,
});

const database = await notion.databases.query({
    database_id: process.env.NOTION_DATABASE_ID || '',
    filter: {
        or: [
            {
                property: 'published',
                checkbox: {
                    equals: true,
                },
            },
        ]
    },
    sorts: [
        {
            property: 'date',
            direction: 'descending',
        },
    ]
});
```

引数で`published`にチェックをつけている(=true)行に絞り込み、`date`の日付降順に並び替えています。

以下のようなレスポンスが返ってきます。

```js
{
  object: 'list',
  results: [
    {
      object: 'page',
      id: 'xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx',
      created_time: '2021-07-28T14:43:00.000Z',
      last_edited_time: '2021-07-29T17:05:00.000Z',
      parent: {
        type: 'database_id',
        database_id: 'xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx'
      },
      archived: false,
      properties: {
        date: {
          id: 'xxxx',
          type: 'date',
          date: { start: '2021-07-29', end: null }
        },
        published: { id: 'xxxx', type: 'checkbox', checkbox: true },
        post: {
          id: 'title',
          type: 'title',
          title: [
            {
              type: 'text',
              text: { content: '20210729', link: null },
              annotations: {
                bold: false,
                italic: false,
                strikethrough: false,
                underline: false,
                code: false,
                color: 'default'
              },
              plain_text: '20210729',
              href: null
            }
          ]
        }
      },
      url: 'https://www.notion.so/20210729-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx'
    }
    ...
  ],
  next_cursor: null,
  has_more: false
}

```

`results`の項目に各行のデータが入っています。次に各記事のIDを使って記事内容を取得します。`notion.blocks.children.list()`で`block_id`に記事のIDを入れてリクエストすると記事内容が取得できます。

```ts
const page = notion.blocks.children.list({ block_id: database.result[0].id });
```

```js
{
  object: 'list',
  results: [
    {
      object: 'block',
      id: 'xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx',
      created_time: '2021-07-29T16:32:00.000Z',
      last_edited_time: '2021-07-29T16:38:00.000Z',
      has_children: false,
      type: 'paragraph',
      paragraph: {
        text: [
          {
            type: 'text',
            text: {
              content: 'xxxxx',
              link: null
            },
            annotations: {
              bold: false,
              italic: false,
              strikethrough: false,
              underline: false,
              code: false,
              color: 'default'
            },
            plain_text: 'xxxxx',
            href: null
          }
        ]
      }
    },
    ...
  ],
  next_cursor: null,
  has_more: false
}

```

`results`の項目に記事に書いた各ブロックの内容が含まれています。タイプごとに項目名が変わりますがシンプルな文章ブロックの場合は`paragraph`で返ってきます。

以下に型定義されているタイプのブロックのみが対応されているようです。

https://github.com/makenotion/notion-sdk-js/blob/ccb2180648bdba19714c40897a6bfda1c506a71a/src/api-types.ts#L38-L48

コードブロックなどは内容が`unsupported`としか返ってこず、記事で使うことができません。

## フロントエンド

### 記事のレンダリング

以上で取得したデータベースと記事の情報を使って以下のようなデータを作り、Next.jsでレンダリングしています。

```ts
export type Post = {
    id: string,
    title: string,
    date: string,
    ymd: string,
    createdTs: string,
    lastEditedTs: string,
    contents: {
        type: 'paragraph',
        text: string | null,
        link: string | null,
        ...
    }[]
};
```

```ts
[
  {
    id: 'xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx',
    title: '20210729',
    date: '2021-07-29',
    ymd: '20210729',
    createdTs: '2021-07-28T14:43:00.000Z',
    lastEditedTs: '2021-07-29T17:05:00.000Z',
    contents: [
      {
        type: 'paragraph',
        text: 'xxxxx',
        link: 'xxxxx'
      }
    ]
  }
]
```

データベースから記事の一覧を取得して`getStaticPaths()`で動的ルーティングを行い、`getStaticProps()`で各記事の内容を取得して静的サイト生成を行います。

https://github.com/yuki-oshima-revenant/notion-blog/blob/main/pages/post/%5Byyyymmdd%5D.tsx

ISRを使ってサイトを更新するために`getStaticProps()`の戻り値には`revalidate`を指定してあります。

記事をレンダリングするコンポーネントはざっくり以下のような様子です。Tailwind CSSを利用しています。対応していないブロックタイプも多いのであまり細かく作っていませんが、リンクのついているブロックは`a`要素にしています。

https://github.com/yuki-oshima-revenant/notion-blog/blob/main/lib/component/PostBody.tsx

```tsx
const PostBody: React.FunctionComponent<{ post: Post }> = ({ post }) => {
    const router = useRouter();
    return (
        <div key={`${post.title}_content`} className="mb-12">
            <div className="mb-2">
                <h2
                    className="text-2xl font-bold cursor-pointer"
                    onClick={() => { router.push(`/post/${post.ymd}`) }}
                >{post.title}</h2>
                <div className="text-gray-500 text-sm my-1">
                    <div>
                        {`Date: ${post.date}`}
                    </div>
                    <div>
                        {`Last Edited: ${moment(post.lastEditedTs).format('YYYY-MM-DD HH:mm:ss')}`}
                    </div>
                </div>
            </div>
            <div className="whitespace-normal break-words">
                {post.contents.map((content, i) => (
                    content.link
                        ? (
                            <div key={i} className="leading-6 my-3">
                                <a 
                                    href={content.link} 
                                    className="text-gray-600 underline my-3" 
                                    target="_blank" 
                                    rel="noreferrer"
                                >
                                {content.text}
                                </a>
                            </div>
                        )
                        : <div key={i} className="leading-6 my-3">{content.text}</div>
                ))}
            </div>
        </div>
    )
};
```

### OGP自動生成

本題と少し離れますが、日記の各ページに対してOGPの自動生成を実装しています。以下の埋め込みで表示されているように記事タイトルなどを含めたOGP画像がページごとに自動生成され、ページヘッダーのメタタグからのリクエストに対して返されます。

https://diary.unronritaro.net/post/20210724

実装は以下の記事を参考にさせていただきました。

https://zenn.dev/tdkn/articles/c52a0cc7bea561

Next.jsの場合は各記事のページの`Head`要素のメタタグからAPI Routeにリクエストを送ります。

```tsx
const Index = ({ post, postsIndex }: InferGetStaticPropsType<typeof getStaticProps>) => {
    if (!post) return <></>;

    return (
        <div>
            <Head>
                <meta property="og:image" content={`https://diary.unronritaro.net/api/ogp?title=${encodeURIComponent(post.title)}`} />
                <meta name="twitter:image" content={`https://diary.unronritaro.net/api/ogp?title=${encodeURIComponent(post.title)}`} />
            </Head>

            ...

        </div>
    )
};
```

そのAPI Route(`/api/ogp`)がURLのパラメータを使って記事のタイトルを取得し、ヘッドレスブラウザを起動して画像を生成してレスポンスします。この部分の実装は、上の記事の最後でより高度な適用例として示されていた以下を参考にさせていただきました。

https://github.com/tdkn/blog/blob/main/pages/api/og/%5B...path%5D.ts

この例ではReactコンポーネントを`ReactDOMServer.renderToStaticMarkup()`を使って静的なマークアップにして、ヘッドレスブラウザでレンダリングしています。その画面をスクリーンショットすることでOGP画像を生成しているわけです。

またVercelを使った際にキャッシュを効かせるためのレスポンスヘッダーも設定されており、参考になりました。

ヘッドレスブラウザにはplaywrightが使われていましたが、Vercelにデプロイした際にplaywright-coreというモジュールが見つからないというエラーが出ました。こちらは単純に

```bash
npm i playwright-core
```

した上でデプロイし直せば解消されました。

## まとめ

この記事ではNotion APIを使った日記サイトの実装例をご紹介しました。Notionのアプリはデスクトップ/ブラウザ/モバイルが統合されて使いやすく、記事の更新はとても便利です。

一方で言及したようにコードブロックなどの要素は現状まだAPIで取得することができません。ですから技術ブログのような複雑な要件にはまだ対応することはできないでしょう。ただし将来的にそれらが実装されれば、full-fledgedなヘッドレスCMSとして活用できるかもしれません。

筆者も今回のようにNotion APIを活用しながら今後の発展を注視していこうと思っています。