hero_picture
Cover Image for 会社のブログをWordPressからNext.jsとNotion APIを使った構成に変更しました

会社のブログをWordPressからNext.jsとNotion APIを使った構成に変更しました

こんにちは。システム開発事業部の加藤です。

この記事の内容について

今回は会社のブログをWordPress から Next.js と Notion API を使った構成に変更したので、

その紹介を兼ねて変更の経緯や実装するにあたって困ったことなどをまとめました。

何か参考になれば幸いです。

構築したもの

最終的に構築したブログの構成図はこちらになります。

構成変更を検討することになった理由、経緯

まず、変更前のブログには下記の不満、要望がありました。

WordPress の管理コストがある

WordPress サーバーを運用・保守を行う必要があり、管理コストがかかります。サーバーの管理コストを減らしたいという要望がありました。

静的ファイルの書き出しに時間がかかる

WordPress のstatic press というプラグインを使ってページを静的に書き出していました。

1回のビルドで全てのページを静的に書き出し S3 へ保存していたため、

ブログを書いてから公開・更新するのに1時間ほどかかってしまっていました。

記事の更新に手間がかかることもあり、記事の内容に修正が必要とならないように公開前に担当者に公開をして良いか確認が必要です。

また、時間がかかるので新規作成した記事をすぐに公開することも出来ませんでした。

そのため、変更にあたってはなるべくビルドに時間をかけたくないという要望がありました。

また他にも、 notion で記事を書いて管理していきたいという要望もありました。

理由は他の有料の cmsサービスなどを使わずとも、 notion で記事が一元管理できること、

機能が豊富なこともあり書きやすいといった点があるからです。

ただ、notion で文章を書きたいということだけであれば、notion を cms として 使わなくても、notion で記事を書いて 別のcms に貼り付けるといった対応でも良いと思います。

実装前に検討したこと

デプロイ先をどうするか

以下へのデプロイを検討しました。

  • Vercel
  • Amplify
  • 自前で用意した S3 & CloudFront

Next.js のホスティング先といえば開発元である Vercel が便利な機能が安定して使える等の理由で 第一選択になると思います。

実装当初は Amplify へデプロイすることを検討していたのですが、途中から Vercel へ変更しました。

Vercel を選択した経緯

まず、Github Actions でビルドした静的ファイルを、S3 と CloudFront にホストする方法が最も安価で、社内に知見があるため検討しました。

ただし、Github Actions 上で Notion APIから取得したデータを用いてSSGする場合、ビルドに時間がかかる可能性があります。また、後述する Notion API へのリクエスト制限に引っかかる可能性もあると考え、候補から除外しました。

次に Amplify を考え、Amplify にデプロイする想定で実装を開始しました。

Amplify に Next.js をデプロイした場合、aws が内部で S3やCloudFrontなどを作成し、ISR・SSR できる環境を用意してくれます。良くも悪くも、aws 上のリソースを意識する必要はありません。

テスト、ビルド、デプロイといったCICD環境も用意されていて、ステージ環境など複数環境を簡単に作成できることも魅力です。

当初は aws に統一したい、Amplify の知見を得たい という気持ちがあり、Amplify にデプロイしていました。

Amplify にデプロイした場合でもISR や SSR は挙動を実現することはできました。

しかし、なるべく記事の更新に時間をかけたくないという要件があり、数分ごと ISR の revalidate である程度最新のコンテンツにしてこの要件に対応しようかと思っていたのですが、これだと 従量課金 で使った分だけお金がかかって勿体無いので別の手段を検討しました。

最終的に Vercel へデプロイ先を変更しました。

基本的に各ページは ISR で 数時間ごとに生成し、

記事の更新に時間をかけたくない場合は、Next.js のOn-Demand ISRを利用して運用してみることにしました。On-Demand ISR については後述します。

ただ、ISRのrevalidateの間隔でページを生成する際にはServerless Function Execution が消費されるので、実行時間の間隔には注意が必要です。

加えて、アカウント単位で消費される (アカウントに連携しているプロジェクト毎ではない) ので、

他プロジェクトも管理することを考えたりすると、なるべく節約する必要があるかと思います。

また、Vercel は定額ですが、プランに応じて制限はあります。商用利用 したい場合 は有料の pro プラン以上である必要があります。

https://vercel.com/docs/concepts/limits/overview

実装に関すること、困ったことなど

すぐにページを更新したい場合

記事の更新後になるべく早く最新の状態にしたいのですが、

前述の通り、revalidate の時間を短くするのは勿体無く、制限にかかる可能性もあるので、

Next.js の On-Demand ISR という機能を利用する方針にしました。

Next.js の api route の機能で下記のようなapiを作成し、コールすることでページが再生成されます。再生成後、次回アクセス時には最新のコンテンツを表示することができます。

1
2export default async function handler(req, res) {
3  // Check for secret to confirm this is a valid request
4  if (req.query.secret !== process.env.MY_SECRET_TOKEN) {
5    return res.status(401).json({ message: 'Invalid token' });
6  }
7
8  try {
9    await res.revalidate('/path');
10    return res.json({ revalidated: true });
11  } catch (err) {
12    // If there was an error, Next.js will continue
13    // to show the last successfully generated page
14    return res.status(500).send('Error revalidating');
15  }
16}

実際の運用では、slack のコマンドから lambda を実行し、上記の api を実行するようにしました。

画像が期限付きで1時間で期限が切れる

Notion API から取得した画像の url は 1時間の期限付きurl となります。

ISR を採用し1時間毎に再生成する方針にしたとしても、アクセスしたタイミングで再生成されますが、アクセス時点では古いキャッシュを返すため、一度は画像のリンク切れのページが返されることになってしまいます。

そのため、SSG や ISR を採用する場合、Notion API から取得した画像 url を そのまま埋め込むのではなく、S3 などに保存した後、その保存先の画像のurl を利用する必要があります。

今回は ISR でページを生成する際に、ページ内の画像を自前で用意した S3 へ保存する処理を実行する形で対応しました。

その際 ISR 実行毎に画像が保存され、使っていない画像がS3バケットに残り続けるのを避けるために、

/pageId/blockId(ブロック毎に与えられるページ内で一意となるid) といったパスでS3に保存するようにし、

生成の度に1度 /pageId/* 配下の画像を全て削除し、保存し直すといった処理を実行するようにしました。

これで使っていない画像が存在することを避けることができます。

画像が既にあれば保存しないといった処理でも良いかもしれません。

1const pagePath = basePath + '/' + page.id;
2
3// ページ内の画像を全然削除
4await deleteImages(pagePath);
5
6const con = blocksWithChildren.map(async (block: any) => {
7  if (block.type !== 'image') return block;
8
9  const value = block['image'];
10  const url = value.file.url;
11
12  const uploadPath = pagePath + '/' + block.id;
13  const path = await uploadImage(uploadPath, url);
14
15  value.file.url = imageUrlAtS3(path);
16  return block;
17});
18contents = await Promise.all(con);

Notion API のリクエスト制限がある

リクエスト数は 平均3回/秒公式に記載されており、リクエスト制限エラーになる可能性があります。

https://developers.notion.com/reference/request-limits#rate-limits

そのため、1秒間に複数リクエストを送信する必要がありそうとなれば注意が必要だと思います。

今回は全ページISRで生成しているため問題にならなかったのですが、

例えば、Notion APIから取得したデータを用いてSSGで静的に書き出すとなるとリクエスト制限に引っかかる可能性があるかと思います。その場合はページのデータをあらかじめ Notion API を利用してダウンロードしておき、ダウンロードした内容を利用してビルドする、1ページ生成毎にスリープ処理を行うなどといった対応が必要になりそうだと思いました。

Notion API でページのデータを取得

Notion API を利用するための sdk が公開されているので、それを使うことになると思います。

実際に noiton database から sdk で 記事の一覧を取得するコードは下記のように書きました。

今回は notion に Status,Dateプロパティを用意し、 公開スターテスの記事のみに絞り日付降順になるようにしました。

1import { Client } from '@notionhq/client';
2
3export const notion = new Client({ auth: process.env.NOTION_SECRET_TOKEN });
4
5export const getPostList = async (
6  params: Omit<RequestParams, 'database_id'>,
7  databaseId: string,
8) => {
9  const response = await notion.databases.query({
10    database_id: databaseId,
11    filter: {
12      and: [
13        {
14          // 公開ステータスのものだけ
15          property: 'Status',
16          status: {
17            equals: '公開',
18          },
19        },
20        ...(params.filters ? params.filters : []),
21      ],
22    },
23    sorts: [
24      // 日付降順
25      {
26        property: 'Date',
27        direction: 'descending',
28      },
29    ],
30    ...params,
31  });
32
33  return response;
34};

Notion API のブロック要素について

最初は本文のHTML が一括で取得できるものかと思っていました。

実際は、Notion APIからのレスポンスには、本文の全てが1つのプロパティにhtml や マークダウンの文字として格納されているわけではありません。Notionで書かれたブロックごとに区切られて返されます。そのため、記事の内容を表示させるのには一手間かかります。

例えばこんな記事を書いた場合、

Notion API で1ページ分の内容を取得した場合のレスポンスはこうなります。

block 毎に区切られています。

1[
2	{
3		object: 'block',
4		id: 'fed34cde-f977-41ac-b200-c8b5c19804de',
5		parent: {
6		type: 'page_id',
7		page_id: '2a510ca7-5eb7-4d52-bf99-3bf7780f4d2e'
8		},
9		created_time: '2023-07-14T09:43:00.000Z',
10		last_edited_time: '2023-07-14T09:43:00.000Z',
11		created_by: { object: 'user', id: 'aa7410d2-7bcf-4e83-86d4-d3f4e2296836' },
12		last_edited_by: { object: 'user', id: 'aa7410d2-7bcf-4e83-86d4-d3f4e2296836' },
13		has_children: false,
14		archived: false,
15		type: 'heading_1',
16		heading_1: { rich_text: [Array], is_toggleable: false, color: 'default' }
17	},
18	{
19		object: 'block',
20		id: 'd099785c-c31a-4d4e-9424-96294842ddb5',
21		parent: {
22		type: 'page_id',
23		page_id: '2a510ca7-5eb7-4d52-bf99-3bf7780f4d2e'
24		},
25		created_time: '2023-07-14T09:43:00.000Z',
26		last_edited_time: '2023-07-14T09:43:00.000Z',
27		created_by: { object: 'user', id: 'aa7410d2-7bcf-4e83-86d4-d3f4e2296836' },
28		last_edited_by: { object: 'user', id: 'aa7410d2-7bcf-4e83-86d4-d3f4e2296836' },
29		has_children: false,
30		archived: false,
31		type: 'heading_2',
32		heading_2: { rich_text: [Array], is_toggleable: false, color: 'default' }
33	},
34	{
35		object: 'block',
36		id: '66e8bc25-3370-4d56-beb8-099e8599b425',
37		parent: {
38		type: 'page_id',
39		page_id: '2a510ca7-5eb7-4d52-bf99-3bf7780f4d2e'
40		},
41		created_time: '2023-07-14T09:43:00.000Z',
42		last_edited_time: '2023-07-14T09:43:00.000Z',
43		created_by: { object: 'user', id: 'aa7410d2-7bcf-4e83-86d4-d3f4e2296836' },
44		last_edited_by: { object: 'user', id: 'aa7410d2-7bcf-4e83-86d4-d3f4e2296836' },
45		has_children: false,
46		archived: false,
47		type: 'heading_3',
48		heading_3: { rich_text: [Array], is_toggleable: false, color: 'default' }
49	}
50]

このブロック要素の type 属性 に合わせてブログ側でcssでスタイルを当てていくことになります。

下記の公式の情報から Notion API でサポートしているブロック要素を確認できます。

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

目次の対応

目次に関しては下記記事を参考にしました。

toc botというライブラリを使用して作成しました。

https://chabelog.com/blog/tocbot#目次を追加する

コードブロックへの対応

コードブロックに対しては https://github.com/react-syntax-highlighter/react-syntax-highlighter というライブラリを使用しました。

https://github.com/react-syntax-highlighter/react-syntax-highlighter

Notion API では embedといった type 名で取得でき、notion 上で選択した言語名なども取得できるため、下記のように実装できました。

1
2switch (type) {
3	case 'code': {
4    const text = value!.rich_text.reduce(
5      (a: string, b) => a + b.plain_text,
6      '',
7    );
8    return (
9      <SyntaxHighlighter
10        language={value!.language}
11        style={nord}
12        showLineNumbers
13      >
14        {text}
15      </SyntaxHighlighter>
16    );
17	}
18}

twiter cardの埋め込みの対応

notion に twitter のリンクを埋め込んだ場合、

Notion API では embedといった type 名で取得できます。

こちらの記事を参考にさせていただきました。

https://blog.35d.jp/2020-04-10-notion-blog-twitter-card

リスト (ul, ol)への対応

Notion API では

箇条書きリストはbulleted_list_item は, 番号付きリストは'numbered_list_item'

というtype 名で同階層でブロックを取得することになります。なので、そのままレスポンスの情報をループするのみでは、ただ li タグだけが並んでしまい、 li タグを ul, ol タグで囲むことができませんでした。

そのため、bulleted_listnumbered_list といった独自のブロックを用意し、その下の階層にブロックを詰めることで対応しました。

つまりNotion APIからは こんな感じの配列で渡ってくるので、

1[
2  { type: "bulleted_list_item" },
3  { type: "bulleted_list_item" },
4  { type: "bulleted_list_item" },
5]

ul, ol で囲むためにこんな感じの階層にしたいのです。

1[
2  {
3    type: "bulleted_lists",
4    children: [
5      { type: "bulleted_list_item" },
6      { type: "bulleted_list_item" },
7      { type: "bulleted_list_item" },
8    ],
9  },
10];

この対応のために書いたコードが下記になります。

1return blocks.reduce((acc, curr) => {
2    // type が "bulleted_list_item numbered_list_item" であれば children プロパティに追加して ネストした構造を作成する
3    if (curr.type === 'bulleted_list_item') {
4      if (acc[acc.length - 1]?.type === 'bulleted_list') {
5        acc[acc.length - 1][acc[acc.length - 1].type].children?.push(curr);
6      } else {
7        acc.push({
8          type: 'bulleted_list',
9          bulleted_list: { children: [curr] },
10        });
11      }
12    } else if (curr.type === 'numbered_list_item') {
13      if (acc[acc.length - 1]?.type === 'numbered_list') {
14        acc[acc.length - 1][acc[acc.length - 1].type].children?.push(curr);
15      } else {
16        acc.push({
17          type: 'numbered_list',
18          numbered_list: { children: [curr] },
19        });
20      }
21    } else {
22      acc.push(curr);
23    }
24    return acc;
25  }, []);
26

これにより ブロックに応じた jsx を返す際に ol, ul の中に li タグを詰めることができました。

1switch (type) {
2	case 'numbered_list': {
3	  return (
4	    <ol className="list-decimal list-inside">
5	      {value.children.map((child: IBlock) => renderBlock(child))}
6	    </ol>
7	  );
8	}
9	case 'bulleted_list_item':
10	case 'numbered_list_item': {
11	  return (
12	    <li>
13	      <Text text={value.rich_text} />
14	      <div className="pl-8">
15	        {!!value.children && renderNestedList(block)}
16	      </div>
17	    </li>
18	  );
19	}
20}

ページネーション

ページネーションは独自に実装が必要です。

notion database から記事一覧を取得する際に一緒に下記のプロパティが取得できるので、それを使って実装しました。

has_more

次のページが存在するかのプロパティ ブール値が格納されています。

next_cursor

次のページが存在する場合の、次のページの最初の記事IDです。一覧取得時に start_cusror として指定することで、その記事以降の一覧を取得できます。

このパラメータを利用して、下記のように実装しましたが、より上手く書けそうではあります。

1const pageSize = 10; // 1ページあたりの表示件数
2const size = page + 1; // 表示するページ 何ページ目か
3let { results, has_more, next_cursor } = await getPostList({
4  page_size: pageSize,
5});
6// 欲しい件数になるまでfetch
7if (has_more && next_cursor && size > results.length) {
8  while (has_more) {
9    const {
10      results: moreResults,
11      has_more: nextHasMore,
12      next_cursor: nextNextCursor,
13    } = await getPostList({
14      page_size: pageSize,
15      start_cursor: next_cursor!,
16    });
17    results.push(...moreResults);
18    has_more = nextHasMore;
19    next_cursor = nextNextCursor;
20    if (size <= results.length) {
21      break;
22    }
23  }
24}
25// ページに表示する件数に絞る
26posts = results.slice((page - 1) * pageSize, page * pageSize)

typescript 型の取り扱い

型に対しては厳密に対応したわけではないのでそこまで困りませんでしたが、

対応としては Notion APIのSDKの中で型定義で使えるところがあれば使い、

上手くsdk からimport できなかったところは、下記のように notion の公式の情報を見つつコピペしながら手動で型を定義しました。

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

1interface IRichText {
2  type: string;
3  text: {
4    content: string;
5    link: null;
6  };
7  annotations: {
8    bold: boolean;
9    italic: boolean;
10    strikethrough: boolean;
11    underline: boolean;
12    code: boolean;
13    color: string;
14  };
15  plain_text: string;
16  href: null;
17}

終わりに

まだ作り込みしないといけない機能はありますが、ひとまず公開しました。

また、このブログを運用するにあたって色々と不都合が出てくる可能性があるかもしれません。

その際はまたブログで共有できればと思います。