🎖️

日本一本気で作ったNotion Blog 2023年決定版

2023/01/05に公開約13,500字

2023年に誰よりも早くに本気のBlogを作ったよ

どーも,Notionは3年前くらいから大好きなのですが,最近使ってきている人があまりに増えていてちょっと好きじゃなくなっています.(本当はすごく嬉しい)というわけで,普通の人にはできないNotion APIを使って,NotionのDatabaseにPageを保存してStatus PropertyをPUBLISHにした記事を自動で公開してくれるNotion Blog(3度目)を作りました.

我ながらかなり完成度が高いので,この満足感をおすそ分けします.

Web開発全般の話をするので,Notionに関する話は多分4割くらいになるかなと.

作ったもの

まずは成果物をゆっくりご覧ください.

https://www.nbr41.com/

一度見ておくとこの記事が楽しく読めると思います.

仕組み

Notion Blogの仕組みは先に話したとおり,Notionが公式のAPIを無料で提供しているので,これを使います.

つまり,Notionに保存したメモのデータを取得することができるんです.

あとはその取得したデータをどのように表示するかをHTML / CSS / JavaScriptで構築するのです.

HTML / CSS / JavaScriptで構築するという表現は間違っていないのですが,近年のフロントエンド界隈の発展は目覚ましく,Next.jsとTailwindCSSを使って作ったといったほうが通じるでしょう.

「Notionのデータを取得するからちょい待って→取得したデータから見た目を構築するね」といったことをサイトに訪れた人ごとに実施するCDNを使ったSSRのWebサイトは今でも多いですが,これは遅いです.

Next.jsのSSGという仕組みを使うことで,「Notionのデータを取得するからちょい待って→取得したデータから見た目を構築するね」を訪問者がたびに毎回しなくても済みます.ビラをばらまくような感じです.なので,SSGで作られるサイトは爆速で表示されるんです.とは言ったものの,記事数が膨大になるとSSRにせざるをえないので,今後はその実装も検討しています.

され,ここからは,あんまり丁寧に話しません.

適当にずらずら書いていきます.

使用した便利なものたち

使いたかったモダンなフレームワーク,ライブラリ,ツールなどをふんだんに使いました

気になるものがあれば,自分で調べて使ってみてください

Next.js

言わずと知れたReactのフレームワークSSRもSSGもISRもお手のもの

node.jsのランタイムで実行できるAPIを作ることもできるので,実質フルスタックフレームワーク

TypeScript

言わずと「JavaScript?あ,TypeScriptのことね」となっているやつ

2023年の初夢は「型のない世界へようこそ」という悪夢で目が覚めました

Tailwind CSS

The State of CSS 2022で今年もトップの座に君臨し続けた人気が止まらないCSSフレームワーク

ただ好みが別れやすいので,職場で安易に提案すると戦火があがる

Mantine UI

割と人気急上昇中のUIライブラリ

怖いくらいのUIの品揃えに加え,便利なhookを提供していたりと個人的にはもっと人気になってもいいと思うツール

ESLint Prettier Husky

みんな大好きコードの六法全書と整形外科

こいつのおかげて戦争のない平和な世界も近い

Huskyでcommit時にルールをチェックしたり,formatしてくれる

余談:最近だとRomaがモダンで良さげなのですが,共存が難しそうなので,メインでは使えていないけどRomaよかったRoman

Storybook

コンポーネント単位の開発に秀でているツール

7.0がBetaでCSF3.0の書き方とかを試してみたかった

Notion

メモアプリだったけど魔改造されすぎて最近では人工知能が搭載している万能ツール

Notion API

Notionに保存したDatabase,Page,Block,Comment,Userのデータを引っ張ってこれる

React Icons

SVG IconのAll in one

Vercel

Hostingに使用.Hobby Planは無料.Next.js作ってる会社と同じなので相性ばっちし

@vercel/og

Vercelが作ったOG画像を動的に生成してくれるツール

Recoil

状態管理ライブラリ

使い勝手がいいけど今回はわりとお留守番気味

SWR

キャッシュ管理ライブラリ

Notionの記事を取得してキャッシュを管理していると思いきやSSGなので,それはしない

コメント機能はリアルタイムで動作するので,そのへんに使用

v2.0からGET以外でも使えるようにパワーアップした

axios

fetchに使用

比較的ゼロコンフィグで良い

エラーハンドリングもしやすい

tiptap

すげーリッチテキストエディタ

Mantine UIに搭載されているが,公式のDocument見ないと使いこなせない

v2.0がBeta版なので,うまく動かんこともある

next-seo

メタ情報とSEOに必要JSON LDの設定を担ってくれる

Algolia

検索とアナリティクスのツール

主にサイト内で記事を検索するのに使用

github-contributions-api

GitHubの草をSVGで返してくれる個人の方が作られた神ツール

Denoでできているのも興味深い

Notion APIに関する実装

さて,本編

まずは記事一覧ページの実装から

Databaseから記事を取得してSSGする

まずはNotionのDatabaseから記事のプロパティ一覧を取得できます(Pageの中のBlocksは含まれませんので,簡単に記事一覧ページを作ることができます.)

export const getStaticProps = async () => {
  const database = await getDatabase(blogDatabaseId);
  const postsArray = await getDatabaseContentsAll({
    database_id: blogDatabaseId,
    page_size: 12,
    sorts: [
      {
        property: 'Date',
        direction: 'descending',
      },
    ],
    filter: {
      property: 'Status',
      select: {
        equals: 'PUBLISH',
      },
    },
  });

  return {
    props: {
      postsArray: postsArray as NotionPageObjectResponse[][],
      properties: database.properties,
    },
    revalidate: 60 * 60 * 24, // 1日
  };
};

書き途中の記事が公開されないように,自分はStatusPUBLISHのものを取得するようにしました.

また,buildしなくても,記事が更新されるようにISRにしてます.

あと,getDatabaseではデータベースに関する情報(TitleとかPropertiesとか)を取得できます.

この情報を使って取得した記事のフィルタリングを可能にしています.

ちなみに私のBlog用のDatabaseのPropertiesはこんな感じです.

おしまい,次は記事の内容ページの実装

Pageのchildrenを取得する

NotionのBlockはchildrenというpropertyを持っています.それは,Pageの中身だったり,ネストされたBlockだったりします.

今回は記事一覧で取得できたPage(これはBlockなので)のIDからそのchildrenを呼ぶことで記事の内容である大量のBlockを取得します.

export const getStaticProps = async (context: { params: Params }) => {
  const page_id = context.params?.page_id as string;
  const page = await getPage(page_id);
  const response = await getChildrenInBlock(page_id);

  const childrenWithOgp = await setOgp(
    response.results as NotionBlockObjectResponse[]
  );

  const post = {
    ...toPostMeta(page as NotionPageObjectResponse),
    description: toMetaDescription(
      response.results as NotionBlockObjectResponse[]
    ),
    children: childrenWithOgp,
  };

  await saveToAlgolia(post);

  return {
    props: {
      post,
    },
    revalidate: 60 * 60 * 24, // 1日
  };
};

export const getStaticPaths = ...

getStaticPathsの記述は省略

page_idはNext.jsのDynamic routingによって取得できるもので,それを使います.

また,childrenの取得とPageの各プロパティなどの情報の取得は別に実施しなければなりません.

getPageはそのPageに関する情報を取得しています.

こちらも先程と同様にISRにしています.

Notion Block Objectをすべてコンポーネント化

実際の記事の内容を表示するためには,さっきのgetChildrenInBlockで取得した大量のBlockを処理しなければなりません.

Notionはオブジェクト指向で作られており,各BlockがObjectになっています.このBlock Objectにはtypeプロパティがありますので,それを見て判断するような仕組みを作ればOKです.

例をあげます.

import type { Heading1BlockObjectResponse } from '@notionhq/client/build/src/api-endpoints';
import type { FC } from 'react';

import { RichText } from '~/components/notion/RichText';

type Props = {
  block: Heading1BlockObjectResponse;
};

export const Heading1: FC<Props> = ({ block }) => {
  return (
    <h1 className="my-2 text-2xl sp:text-lg">
      <RichText text={block.heading_1.rich_text} />
    </h1>
  );
};

こちらはBlock Objectが見出し1だった場合に表示されるコンポーネントです.

これを表示したいtypeの分だけ作成し,

export const blockToJsx = (block: NotionBlockObjectResponse) => {
  const blockType = block.type;

  switch (blockType) {
    case 'paragraph':
      return <Paragraph block={block} />;
    case 'heading_1':
      return <Heading1 block={block} />;
    case 'heading_2':
      return <Heading2 block={block} />;
    case 'heading_3':
      return <Heading3 block={block} />;
    case 'callout':
      return <Callout block={block} />;
    case 'bulleted_list_item':
      return <BulletedListItem block={block} />;
    case 'numbered_list_item':
      return <NumberedListItem block={block} />;
    case 'to_do':
      return <ToDo block={block} />;
    case 'code':
      return <Code block={block} />;
    case 'quote':
      return <Quote block={block} />;
    case 'bookmark':
      return <Bookmark block={block} />;
    case 'link_preview':
      return <LinkPreview block={block} />;
    case 'image':
      ...省略

といった具合で場合わけして表示します.

さらに,Block Object内にはRich Textが存在しており,その中身を見てテキストを太字にしたり,aタグでhrefをつけてみたいなことをしなければならないので,その処理も必要になります.(これに関しては割愛します)

BookmarkのUIのためのOGPの取得

さらに,Bookmarkに関してはリンク先のOGPはBlock Objectに含まれていませんので,自分でOGPを取得して挟み込む処理を書く必要がありました.

これがさっきの

  const childrenWithOgp = await setOgp(
    response.results as NotionBlockObjectResponse[]
  );

の処理の招待です.

まぁ,やらなくてもいいのですが,これでリンクをNotionやZennのようにキレイなUIで表示することができます.

期限付きURLに対する対応

Notionに保存した静的ファイルはS3に期限付きURLが発行され保存されます.

ちなみにこれは期限付きだと非公開であっても,誰でも見ることができます.

んで,今回の問題はBuildなどをしたときにAPIで取得したURLと異なってしまうと表示されないということです.

これはISRの性質上わりとどうしようもないかなと思っています.

とは言え,ページを更新すれば,ISRで指定した期間内の最新のNotionの情報を持ってくるはずなので,表示されるでしょう.

今回やったことはNext.jsのImage CommentのonErrorイベントで画像に関するエラーを検知して,表示されていない場合は更新を促すUIを表示するようにしました.

というわけでコツは気合で頑張ることです.

次はいいね機能

いいね機能

人間の根源的な承認欲求を満たしてくれる大切な機能を実装します.

Notion APIにはPageのPropertyを更新するPATCHメソッドがあるのでこれを使います.

また,今回は毎回ページを表示する度に最新のいいねの数を取得する必要があります.

SWRを使ったHookを作りました.

const url = '/api/notion-blog/likes';
const initialData = { count: 0 };

export const useLikes = (page_id: string) => {
  const { data, isLoading, error, mutate } = useSWR<{ count: number }>(
    page_id ? `${url}/${page_id}` : null,
    getFetcher
  );
  const revalidate = useCallback(() => mutate(), [mutate]);

  const {
    trigger,
    isMutating,
    error: mutateError,
  } = useSWRMutation(page_id ? `${url}/${page_id}` : null, patchFetcher, {
    onSuccess: revalidate,
  });

  return {
    data: data || initialData,
    revalidate,
    isLoading,
    error,
    trigger,
    isMutating,
    mutateError,
  };
};

悩んだのですが,今回はuseSWRuseSWRMutationを一緒のHookにまとめてみるということをやってみました.

useSWRMutationはv2.0から追加されたGET以外に使用できる便利な機能で,APIに関する処理を全てSWRに管理させることができるようになりました.

こんな感じで通常のCRUDみたいな感じでいいねができるようになりました.

ただ,ログインしなくてもいいねできるので,何回でもできます.

NotionのPageにCommentできるように

こちらもいいねと同じようにSWRを使い,Notion APIにCommentをPOSTできるものがあるので,それを使用できました.ただ,Notion APIではCommentはGETとPOSTしかないので,編集と削除はできないようですね.

型について

TypeScriptを使っている以上はNotion APIで取得したデータの型を上手に使いこなさなければなりません.

NotionのBlock Object型はとても複雑です.

公式のNotion SDKから割と型をimportできるので,それを自分で再定義して整理してから使っていました.

一応貼っておきます.

import type {
  BlockObjectResponse,
  CommentObjectResponse,
  CreateCommentParameters,
  DatabaseObjectResponse,
  ListCommentsResponse,
  PageObjectResponse,
  RichTextItemResponse,
} from '@notionhq/client/build/src/api-endpoints';

/* Replace */
export type NotionDatabaseObjectResponse = DatabaseObjectResponse;
export type NotionPageObjectResponse = PageObjectResponse;
export type NotionBlockObjectResponse = BlockObjectResponse;
export type NotionListCommentsResponse = ListCommentsResponse;
export type NotionCommentObjectResponse = CommentObjectResponse;
export type NotionRichTextItemResponse = RichTextItemResponse;
export type NotionCreateCommentParameters = CreateCommentParameters; // Request only

/* Extract */
export type NotionDatabaseProperty = NotionDatabaseObjectResponse['properties'];
export type NotionDatabasePropertyConfigResponse =
  NotionDatabaseObjectResponse['properties'][string];
export type NotionSelectPropertyResponse = Extract<
  NotionDatabasePropertyConfigResponse,
  { type: 'select' }
>['select']['options'][number];
export type NotionSelectColor = NotionSelectPropertyResponse['color'];
export type NotionRichTextItemRequest =
  CreateCommentParameters['rich_text'][number]; // Request only

/* Custom */
export type NotionPostMeta = {
  id: string;
  icon: string;
  title: string;
  description?: string;
  category: string;
  date: string;
  updatedAt: string;
  tags: NotionSelectPropertyResponse[];
  likes: number;
};
export type NotionPost = NotionPostMeta & {
  children: NotionBlockObjectResponse[];
};
export type NotionBlogProperties = {
  categories: NotionSelectPropertyResponse[];
  tags: NotionSelectPropertyResponse[];
};
export type NotionBlogPropertiesWithCount = {
  categories: (NotionSelectPropertyResponse & { count: number })[];
  tags: (NotionSelectPropertyResponse & { count: number })[];
};

Extract,はimportできなかったので抽出したもの

Customは,使いやすいように自分で加工したあとの型

整理しているときに2回くらい気を失いかけました

Table of contentsの追加

おそらく一番時間がかかったであろう,目次機能の追加の話です.

こちらは

目次の項目となる見出しのBlockと抽出し表示する処理と,

その見出しが画面の中に存在しているかどうかを管理する処理を,

並行してやらなきゃいけなく,それぞれは別の次元のコンポーネントだったため,Recoilを使って画面内にある見出しBlockのIDを管理することにしました.

export const Heading2: FC<Props> = ({ block }) => {
  const setInViewHeading = useSetRecoilState(inViewHeadingIdsAtom);
  const { ref, entry } = useIntersection({
    threshold: 1,
    rootMargin: '0px',
  });

  useEffect(() => {
    if (!entry) return;

    if (entry?.isIntersecting) {
      setInViewHeading((prev) => [...prev, block.id]);
    } else {
      setInViewHeading((prev) => prev.filter((id) => id !== block.id));
    }
  }, [entry, block.id, setInViewHeading]);

  return (
    <h2
      id={block.id}
      className="my-6 flex items-center gap-2 px-3 text-xl shadow-[-1px_-1px_6px_#ccc,4px_4px_1px_#1E293B] sp:text-base"
      ref={ref}
    >
      <OutlineBlockIcon size={24} />
      <RichText text={block.heading_2.rich_text} />
    </h2>
  );
};

これは実際の見出し2のコンポーネントです.

MantineのuseIntersectionを使うと画面内にあるかを簡単に監視することができるので便利です.

あとはこのinViewHeadingIdsAtomから目次のUIの方で,今どの項目が画面に入っているかを確認していい感じに表示するだけです.

Mantine UIを使って簡単に実装

さて,お次はMantine UIを使うことで,楽に実装できた機能を簡単に紹介します.

ページネーション

usePaginationってhookとPaginationのUIがあるので,それを使えば終了

usePaginationが思ったより便利ではなかった.(usePaginationから返ってきた値をPaginationのコンポーネントのpropsにぶち込んだらいい感じになるのを期待していた)

フィルタリング

Selectで単数選択をおしゃれに

MultiSelectで選択肢をテキスト検索可能に

多分10分くらいで終わったキレイなUIだし最強

スポットライト検索

サイトのどこにいても⌘ + Kで検索バーが出るようにできた

_app.tsxでラップするだけで使える

algoliaのAPIと連携するためのちょっと設定が手こずった

パンくず

BreadcrumbsってコンポーネントがパンくずのUI作ってくれるけど,pathからテキストに変換する作業は自分でやらなきゃいけない

Notification

これがMantine UIの真骨頂だと思っている

関数を呼ぶだけでこんがりトーストが出現する神

Scroll Top Button がすんって出る

すんって出る

こんな感じのTransitionってのでラップするとTopにいるときだけ非表示になるのは便利

<Transition transition="slide-up" mounted={scroll.y > 0}>

その他の実装

認証

ログインしてコメントする機能があるのですが,

認証は有名なNext Authを使ったことなかったので使ってみました

Sessionを管理してくれるツールで,他のと何が違うんだろと思ったら,アカウント連携できる数が段違いだった.

というわけで5分でGoogleログインが実装できました.

Sessionの期間とかも設定できる.

Algoliaによるスポットライト検索

せっかくなので無駄にAlgoliaで実装しました.

Indexingという検索対象のObjectをAlgoliaのAPIでAlgoliaのDBに保存することができます.

これを記事内容ページのgetStaticPropsで実行すれば,build時に保存してくる.

さっきの

await saveToAlgolia(post);

って部分ですね.

それとは別に文字列から検索結果を返してくれるAPIもあるので,スポットライト検索に入力されたときにそのAPI叩いて表示すれば終了.

Dummy dataの使用

localhost:3000で開発するときに,毎度大量のNotionのデータを取得してしまうのはなんかもったいないし,重いので環境変数を適当にいじってlocalhostで起動しているときはダミーデータを使用するようにしました.(このへんのお作法は正直よくわからん)

export const getStaticProps = async () => {
  if (process.env.ENVIRONMENT === 'local') {
    return {
      props: {
        postsArray: dummy_notion_pages_array as NotionPageObjectResponse[][],
        properties: dummy_notion_database_properties as NotionDatabaseProperty,
      },
    };
  }

ダミーデータは取得したデータをConsoleに出して右クリするとcopy objectってのがあるので,それでJSONでコピーできます.

おわりに

かなり浅く書き殴ってしまいましたので,細かなところは是非ソースコードをご覧ください.
質問や提案なども歓迎いたします.

https://github.com/nbr41to/noblog

Discussion

ログインするとコメントできます