📑

Next.js(App Router) と NotionAPI で簡単なブログサイトを構築

2023/12/11に公開

Notionで作成した記事をNotionAPIで取得し、Next.jsを使ってブラウザに表示させます。
今回は、ブログの作成機能はなく、Notionで作成したページを表示するだけです。

▼完成品
https://nextjs-notion-blog-template.vercel.app/
ブログ一覧ページ

ブログ詳細ページ

使用技術

  • Next.js(14.0.3)
  • React(18.0.0)
  • TypeScript(5.0.0)
  • Tailwind CSS(3.3.0)
  • notion-to-md(2.6.0):Notionから取得したブロックをMarkdownに変換する
  • react-markdow(9.0.1):Markdownに変換したNotionのデータをHTMLにする

大まかな手順

  1. Notion APIのセットアップ
  2. Next.jsプロジェクトの構築
  3. Notionクライアントとデータ取得
  4. フロントエンドの構築

作成方法

Notion APIのセットアップ

Notion APIを使用するには、まずNotionで以下のセットアップを行います。

  1. Notionでデータベースを作成
  2. インテグレーションの作成
  3. APIキーの取得
  4. データベースとインテグレーションの紐付け
  5. データベースIDの取得
    Notionアカウントは無料のプランで問題ありません。
    今回は以下のようなデータベースを作成します。データベースのレコード(行)が一つのブログ記事となります。

    ブログのタイトルや本文はChatGPT・サムネ画像はCanvaと、それぞれAIに作成してもらいました。

インテグレーションの作成 / APIキーの取得

以下のページで新しいインテグレーションを作成します。
https://www.notion.so/my-integrations
シークレットが生成されるのでコピーしておきます。

データベースとインテグレーションの紐付け

データベース右上のメニューから「コネクトの追加」を選択し、作成したインテグレーションをデータベースに紐付けます。

データベースIDの取得

データベースのURLからコピーしておきます。以下のようなURLの場合、「XXX..X」の部分がデータベースIDです。

https://www.notion.so/XXXXXXXXXXX?v=aaaaaaaaaa

Next.jsプロジェクトの構築

プロジェクトの作成

任意のディレクトリで以下のコマンドを実行します。

npx create-next-app@latest

Notionクライアントとデータ取得

Notionからデータを取得するコードを記述していきます。
今回はNext.jsのサーバーコンポーネントでデータを取得する関数を作成し、その関数をpage.tsxで呼び出します。

notionhqのインストール

以下のコマンドを実行し、notionhqをインストールします。

npm i @notionhq/client

.env.localにAPIキーとデータベースIDを記載する

Notionのセットアップ時にコピーしたAPIキーとデータベースIDを.env.localに記載します。

NOTION_TOKEN=secret_XXXXXXXX
DATABASE_ID=XXXXXXX

クライアントの初期化

ルートディレクトリに/lib/notion/notion.tsを作成します。
Notion APIを使用するために、@notionhq/client ライブラリから Clientクラスをインポートし、新しいインスタンスを作成します。
インスタンスを作成する際には、環境変数から取得したAPIキー(トークン)を auth プロパティに設定します。

const { Client } = require("@notionhq/client");

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

このインスタンスを使用してNotionのAPIを呼び出します。

データベースの全レコードを取得する関数を作成

まず、ブログの一覧ページに表示するために、データベースから全レコードを取得します。


interface NotionPost {
  id: string;
  title:  string;
  date: string;
  types: string[];
  files: string[];
  author: string
};

export async function getAllPosts(): Promise<NotionPost[]> {
  const response = await notion.databases.query({
    database_id: process.env.DATABASE_ID,
    sorts: [
      { //createdateカラムの値で降順に並べる
        property: 'createdate',
        direction: 'descending',
      },
    ]
  })
  const posts = response.results
  const postsProperties = posts.map((post:any) => {
    // レコードidの取り出し
    const id = post.id

    // titleプロパティの取り出し
    const title = post.properties.title.title[0]?.plain_text;

    // dateプロパティの取り出し
    const date = post.properties.createdate.date.start;

    // multi_selectプロパティの取り出し(例:types)
    const types = post.properties.types.multi_select.map((item:any) => item.name);

    // filesプロパティの取り出し(例:file)
    const files = post.properties.file.files.map((file:any) => file.file.url);

    // peopleプロパティの取り出し(例:author)
    const author = post.properties.author.select.name;

    // プロパティをまとめたオブジェクトを返す
    return { id, title, date, types, files, author };
  });
  return postsProperties
}

notion.databases.queryで取得したデータをresponseに格納し、そのresultsプロパティを参照することで、それぞれカラムの値を持った各レコードを含む配列を取得することができます。
その配列をmapで展開し、各レコードの値を持つ一つのオブジェクトに格納して、新しい配列を作成し、returnで返します。
これで、ブログの一覧ページでgetAllPosts()を実行することで各レコードのデータを持ったオブジェクトの配列を取得できます。
また、post.idはレコードのidで、これは各レコードの中にあるブログの本文を取得するために必要なので、このタイミングで取得しておきます。

ブログの本文を取得する関数を作成

データベースの各レコードには、ブログの本文が記載されています。

しかしブログの本文にアクセスするには、先ほどのnotion.databases.queryではアクセスできないため、別のAPIを呼び出す必要があります。
まず、以下のコマンドを実行してnotion-to-mdをインストールします。

npm i notion-to-md

先ほどのnotion.tsに、以下のコードを記述します。

import { NotionToMarkdown } from "notion-to-md";

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

export async function getPageContent(pageId: string) {
  const mdblocks = await n2m.pageToMarkdown(pageId, 2);

  return mdblocks;
}

getPageContent関数はブログの詳細ページが読み込まれた際に実行されることを想定し、引数にpageIdを受け取り、どのブログの本文を取得するか決めます。
また、取得した本文をMarkdown表記にしたいため、notion-to-mdライブラリを使用します。

const mdblocks = await n2m.pageToMarkdown(pageId, 2);
console.log("Markdown Content:", mdblocks);

pageToMarkdownでページ内のブロックを取得することで、本文を取得することができます。
コンソールログで確認すると以下のような配列になっています。

Markdown Content: [
  {
    type: 'paragraph',
    blockId: '~~~~~',
    parent: '皆さんは猫が楽器を演奏する姿を想像したことはありますか?',
    children: []
  },
  {
    type: 'paragraph',
    blockId: '~~~',
    parent: '今日は、音楽好きな猫が最新の趣味としてトロンボーン演奏に挑戦している、という驚きの話をお届けします。',
    children: []
  },
  {type: 'numbered_list_item',
    blockId: '~~',
    parent: '1. **コミュニケーションの重要性**\n' +
      '猫は非言語コミュニケーションの達人です。耳の位置、尻尾の動き、体の姿勢など、細かいジェスチャーで感情や意思を伝えます。この面接では、言葉に頼ることなく、身体言語でコミュニケーションするスキルが試されます。',
    children: []
  },

Markdown形式になったブログの本文が、ブロックごとにオブジェクトとしてわかれた一つの配列になっていることがわかります。
これを、ブログの詳細ページでmapで展開することで本文を表示することができます。

ブログの詳細ページに表示するタイトル、作成日時、執筆者のデータを取得する関数を作成

最後に、ブログの詳細ページに本文と一緒に表示するタイトルなどを取得する関数を作成します。

interface NotionPostInfo {
  title:  string;
  date: string;
  author: string
};

export async function getPageInfo(pageId: string): Promise<NotionPostInfo> {
  const response = await notion.pages.retrieve({ page_id: pageId });
  const pageInfo = response.properties

  const title = pageInfo.title.title[0]?.plain_text
  const date = pageInfo.createdate.date.start
  const author = pageInfo.author.select.name
  return { title, date, author }
}

基本的にデータベースから全レコードを取得してきた時と同じです。
ただ今回は.databases.queryではなく、.pages.retrieve({ page_id: pageId })でページ単体のデータをAPIで呼び出しています。
一覧の時と同じデータを使うことになるので、ここは一回の取得で賄えるようにした方がより良いかもしれません🤔

フロントエンドの構築

最後にブログの一覧ページや詳細ページを作成して終わりです。

ブログの一覧ページ

今回はsrc/app/page.tsxを一覧ページとします。

import Image from 'next/image';
import { getAllPosts } from '../../lib/notion/notion'
import Link from 'next/link';

export const revalidate = 60

export default async function Home() {
  const postsProperties = await getAllPosts()

  return (
    <div className='container mx-auto'>
      <main className="flex min-h-screen flex-col items-center justify-center p-8 lg:w-5/6 mx-auto">
        <h1 className="text-md  md:text-xl font-bold mb-6">ブログ一覧</h1>
        <div className="grid gap-8 p-3 md:p-10 pt-5 md:grid-cols-2 lg:grid-cols-3">
          {postsProperties.map((post, index) => (
            <Link href={`/blog/${post.id}`} key={index} className="border rounded-lg p-10 shadow-lg transition-shadow hover:shadow-xl">
              <h2 className="text-sm  sm:text-md  md:text-lg font-semibold mb-2">{post.title}</h2>
              <p className="mb-2 text-gray-600">{post.date}</p>
              <div className="mb-4">
                <div className='flex flex-wrap gap-2' >
                {post.types.map((type, typeIndex) => (
                    <span key={typeIndex}  className="mr-2 bg-gray-800 px-2 py-1 rounded-2xl text-xs hidden sm:block text-white">{type}</span>
                    ))}
                </div>
              </div>
              <div className="mb-4">
                {post.files.map((file, fileIndex) => (
                  <Image 
                    key={fileIndex} 
                    src={file} 
                    alt="Post image" 
                    width={960}
                    height={540}
                    className="w-full mb-2"/>
                ))}
              </div>
              <p>author: {post.author}</p>
            </Link>
          ))}
        </div>
      </main>
    </div>
  )
}

ブログサイトはデータの取得をそれほど頻繁に行う必要はないので、revalidateを指定しています。
今回は60秒にしていますが、もっと長い時間で問題ないと思います。

export const revalidate = 60

getAllPosts関数を使用し、ブログの一覧データを取得します。

export default async function Home() {
  const postsProperties = await getAllPosts()

今回は各ブログのレコードのidをURLにします。

 {postsProperties.map((post, index) => (
   <Link href={`/blog/${post.id}`} 

これで各ブログのカードをクリックするとそのブログの詳細ページ(/blog/blogid)に遷移させられます。

ブログの詳細ページ

最後にブログの詳細ページを作成します。
まず、Notionから取得した本文はMarkdownの形に変換していたため、それをHTMLに変換するためのライブラリをインストールします。

npm i react-markdown

次に、src/app/blog/[id]/page.tsx に以下のコードを記述します。
BackButtonコンポーネントは別途作成が必要です。

import BackButton from "@/app/components/BackButton"
import { getPageContent, getPageInfo } from "../../../../lib/notion/notion"
import ReactMarkdown from 'react-markdown';

export default async function Page({ params }: { params: { id: string } }) {
  const pageContents:any = await getPageContent(params.id)
  const pageInfo = await getPageInfo(params.id)

  return (
    <>
      <div className="container mx-auto p-20 md:w-4/5 lg:w-3/5">
        <div className="">
          <BackButton />
        </div>
        <div className="text-center px-5 lg:px-20">
          <p>{pageInfo.title}</p>
        </div>
        <div className="text-right px-5 lg:px-20 text-gray-500">
          <p>{pageInfo.date}</p>
        </div>
        <div className="text-right px-5 lg:px-20 text-gray-500">
          <p>{pageInfo.author}</p>
        </div>
        <div className="py-10 px-5  lg:p-10 lg:px-20">
          {pageContents.map((content:any, index:any) => {
            const formattedMarkdown = content.parent.replace(/\n/g, '  \n');
            return (
              <div className="pt-3 list-decimal" key={index}>
                <ReactMarkdown
                  components={{
                    ol: ({node, ...props}) => <ol className="list-decimal list-inside pb-2" {...props} />,
                    li: ({node, ...props}) => <li {...props} />
                  }}
                >
                  {formattedMarkdown}
                </ReactMarkdown>
              </div>
            )
          })}
        </div>
      </div>
    </>
  )
}

({ params }: { params: { id: string } }) でURLの動的な値(=ページのid)を取得し、それをgetPageContent関数とgetPageInfoの引数に設定して、ブログの本文とタイトル等を取得します。

export default async function Page({ params }: { params: { id: string } }) {
  const pageContents:any = await getPageContent(params.id)
  const pageInfo = await getPageInfo(params.id)

getPageContent関数で取得した本文は各ブロックをオブジェクトとした一つの配列であるため、mapで展開します。
こうすることで、各文章にスタイルを当てることができ、より柔軟に表示することができます。

<div className="py-10 px-5  lg:p-10 lg:px-20">
  {pageContents.map((content:any, index:any) => {
    const formattedMarkdown = content.parent.replace(/\n/g, '  \n');
    return (
      <div className="pt-3" key={index}>
	<ReactMarkdown
	  components={{
	    ol: ({node, ...props}) => <ol className="list-decimal list-inside pb-2" {...props} />,
	    li: ({node, ...props}) => <li {...props} />
	  }}
	>
	  {formattedMarkdown}
	</ReactMarkdown>
      </div>
    )
  })}
</div>

react-markdownを適用するには<ReactMarkdown> </ReactMarkdown>でmarkdownの文字列を囲むだけです。
ただ、今回のようにTailwindcssを利用している場合は注意が必要です。
Tailwindcssはデフォルトで番号付きリストの番号を描画しないようにしているため、className="list-decimal"<ol>タグに設定する必要があります。
そのため、ReactMarkdownコンポーネントのcomponentsオプションでその設定を追加する必要があります。
また、番号付きリストの後ろに来る改行\nも修正することで、番号付きリストの見出しに続く文章を適切に改行できます。

const formattedMarkdown = content.parent.replace(/\n/g, '  \n');
<ReactMarkdown
  components={{ //この部分
    ol: ({node, ...props}) => <ol className="list-decimal list-inside pb-2" {...props} />,
    li: ({node, ...props}) => <li {...props} />
  }}
>
  {formattedMarkdown}
</ReactMarkdown>

これでブログの詳細ページを表示させることができると思います。

Discussion