📘

【Notionブログ】Next.jsでカテゴリー別記事一覧ページを作成する

2023/09/08に公開

はじめに

オウンドメディア制作やポートフォリオ作成のためにNotion APIを用いたNotionブログ作成にチャレンジされる方は多くいらっしゃいます。ただ公開されているNotionブログの個別投稿ページでは、ほとんどのURLが、
https://example.site/[記事タイトル]
または
https://example.site/[ID]
となっています。

今回はNext.jsで①カテゴリー別の記事一覧ページの作成、
(https://example.site/[カテゴリー名])
②①に基づいた個別投稿ページのURLの設定を行なっていきます。
(https://example.site/[カテゴリー名]/[記事タイトル])

プロジェクトによっては、上記のような対応が必要になるかと思いますが、それをまとめたサイトが見当たらなかったため、記事にしました。今回はNotionAPIにはNode.jsからアクセスして、静的サイトジェネレーター(SSG)を利用して作成しています。

↓完成後のサンプルサイトです。
https://test-notion-psi.vercel.app/

■ 参考文献

https://developers.notion.com/

Next.jsのセットアップからNotionブログにインストールまで

今回はこの部分は省略します。以下のリンクの記事を参考にしてみてください。
https://zenn.dev/kalubi/articles/de85ed8c741dc3
この記事についても、公開されている「notion-blog-nextjs」をベースに作成しています。
https://github.com/samuelkraft/notion-blog-nextjs

■ 初期の状態はこんな感じ

下の画像は、作成したNotionのページです。今回例としてペットについての記事を8件書いています。

下の画像は、先ほどのNotionから情報を取得したサイトのトップページです。

■ ディレクトリの作成

pagesの直下に[category],その直下に[title]とindex.jsx、[title]の直下にindex.jsxを配置しました。つまり[category]直下のindex.jsxが、カテゴリー別記事一覧ページ、[title]直下のindex.jsxが個別投稿ページになります。今回は必要最低限の実装になるので、各々で拡張子や必要なcssの設定を行なってください。

カテゴリー別記事一覧ページ

■ 必要な関数を設定する。

記事のカテゴリーを取得する関数を設定します。

lib/notion
export const getPostsByCategory = async (databaseId, categoryName) => {
  const response = await notion.databases.query({
    database_id: databaseId,
    filter: {
      property: "Categories",
      multi_select: {
        contains: categoryName,
      },
    },
  });
  return response.results;
};

notionが用意しているfilterを使います。上記のproperty名と下の画像の赤枠の部分は合致するようにしてください。今回は『Category』にしています。

■ ページを実装する

pages/[category]/index.jsx
import Head from "next/head";
import { getDatabase, getPostsByCategory } from "../../lib/notion";
import Link from "next/link";
import { databaseId } from "../index.jsx";

export default function Category({ page }) {
  if (!page) {
    return <div />;
  }
  return (
    <div>
      <Head>
        <title>
          {page[0].properties.Categories.multi_select[0].name}のページ
        </title>
        <link rel="icon" href="/favicon.ico" />
      </Head>

      <div>
        <h1>{page[0].properties.Categories.multi_select[0].name}の記事一覧</h1>
      </div>
      <article>
        <ol>
          {page.map((post) => {
            const date = new Date(post.last_edited_time).toLocaleString(
              "en-US",
              {
                month: "short",
                day: "2-digit",
                year: "numeric",
              }
            );
            return (
              <li
                key={post.properties.Name.title[0].plain_text}
                className={styles.post}
              >
                <h2>
                  <div>{post.properties.Name.title[0].plain_text}</div>
                </h2>
                <p className={styles.postDescription}>{date}</p>
                <Link
                  href={`/${post.properties.Categories.multi_select[0].name}/${post.properties.Name.title[0].plain_text}`}
                >
                  Read post →
                </Link>
              </li>
            );
          })}
        </ol>
      </article>
    </div>
  );
}

export const getStaticPaths = async () => {
  const database = await getDatabase(databaseId);
  const allTags = database
    .map((post) => post.properties.Categories.multi_select)
    .reduce((acc, categories) => acc.concat(categories), [])
    .filter((category, index, self) => self.indexOf(category) === index);
  return {
    paths: allTags.map((category) => ({ params: { category: category.name } })),
    fallback: true,
  };
};

export const getStaticProps = async (context) => {
  const { category } = context.params;
  const page = await getPostsByCategory(databaseId, category);
  return {
    props: {
      page,
    },
    revalidate: 1,
  };
};

関数名"getDatabase"はデフォルトでnotion.jsに設定してあります。必要に応じて見た目の部分は変更してください。

.map((post) => post.properties.Categories.multi_select)
.reduce((acc, categories) => acc.concat(categories), [])
.filter((category, index, self) => self.indexOf(category) === index);

ここでは、取得したすべてのカテゴリーを一つの配列に結合し、その後、重複するカテゴリーを削除しています。

■ プレビュー

カテゴリー別記事一覧ページができました。

個別投稿ページ

■ 必要な関数を設定する。

個別記事の投稿内容を取得する関数を設定します。デフォルトで実装されている関数"getDatabase","getPage","getBlocks"に加えて、getIdFromTitleという関数を作ります。

lib/notion
export const getIdFromTitle = async (title, database) => {
  const page = database.find(
    (post) => post.properties.Name.title[0].plain_text === title
  );
  return page ? page.id : null;
};

この関数は、指定されたtitleと一致するデータベース内のページ(またはレコード)を探して、そのページのUUIDを返します。

■ ページを実装する

pages/[category]/[title]/index.jsx
import { Fragment } from "react";
import Head from "next/head";
import {
  getDatabase,
  getPage,
  getBlocks,
  getIdFromTitle,
} from "../../../lib/notion";
import Link from "next/link";
import { databaseId } from "../../index.jsx";

[省略]

export default function Post({ page, blocks }) {
  if (!page || !blocks) {
    return <div />;
  }
  return (
    <div>
      <Head>
        <title>{page.properties.Name.title[0].plain_text}</title>
        <link rel="icon" href="/favicon.ico" />
      </Head>
      <article>
        <h2>
          <Text text={page.properties.Name.title} />
        </h2>
        <section>
          <div>
            カテゴリー名:
            {page.properties.Categories.multi_select[0].name}
          </div>
          <div>
            {blocks.map((block) => (
              <Fragment key={block.id}>{renderBlock(block)}</Fragment>
            ))}
          </div>
          <Link href="/">← Go home</Link>
        </section>
      </article>
    </div>
  );
}

export const getStaticPaths = async () => {
  const database = await getDatabase(databaseId);
  const paths = database.flatMap((post) =>
    post.properties.Categories.multi_select.map((category) => ({
      params: {
        category: category.name,
        title: post.properties.Name.title[0].plain_text,
      },
    }))
  );

  return {
    paths: paths,
    fallback: true,
  };
};

export const getStaticProps = async (context) => {
  const { title } = context.params;
  const database = await getDatabase(databaseId);
  const uuid = await getIdFromTitle(title, database);

  if (!uuid) {
    return { notFound: true };
  }

  const page = await getPage(uuid);
  const blocks = await getBlocks(uuid);

  return {
    props: {
      page,
      blocks,
    },
    revalidate: 1,
  };
};

■ プレビュー

個別投稿ページができました。

完成!!

ぜひ実践してみてください。今回は必要最低限の実装に留め、リファクタリングなども行なっていないので、その点ご容赦ください。

Discussion