Zenn
📓

Notion + Next.jsでブログサイトを作ってみた

2025/03/16に公開
1

はじめに

NotionをヘッドレスCMS代わりとするブログサイトを作ってみます。
これを基に作ったのがこちら
https://niconicos-blog.netlify.app/
参考にさせていただいた記事
https://zenn.dev/jinku/articles/722e8f93e87111
https://tech.kikagaku.co.jp/entry/2024/01/24/090000

環境構築

Next.jsのセットアップ

npm

npx create-next-app

pnpm

pnpm dlx create-next-app
✔ What is your project named? … my-blog
✔ Would you like to use TypeScript? … Yes
✔ Would you like to use ESLint? … No
✔ Would you like to use Tailwind CSS? … Yes
✔ Would you like your code inside a `src/` directory? … Yes
✔ Would you like to use App Router? (recommended) … Yes
✔ Would you like to use Turbopack for `next dev`? … Yes
✔ Would you like to customize the import alias (`@/*` by default)? … No

今回はESLintではなくBiomeを使用していますが、導入は割愛します。

必要なライブラリをインストール

pnpm i @notionhq/client notion-to-md react-markdown remark-gfm

今回使うライブラリは以下です。

  • @notionhq/client - NotionAPIを扱うためのライブラリ
  • notion-to-md - Notionから得たページデータをマークダウンテキストに変換するライブラリ
  • react-markdown - Reactでマークダウンテキストを表示するライブラリ
  • remark-gfm - react-markdownのGitHub系マークダウン用のプラグイン

Notion側のセットアップ

  1. Notionの任意のページに以下のようなプロパティのデータベースを作成
    必要ないプロパティは適宜削除してください
  2. 「テーブルビュー」などをクリックし、ビューのリンクをコピーをクリック
  3. コピーしたリンクの以下の部分がデータベースIDになるので、控えておきます。
    https://www.notion.so/<データベースID>?v=XXXXX
    

NotionAPIをセットアップ

  1. NotionAPIインテグレーションの作成ページにアクセス
    https://www.notion.so/my-integrations
  2. 新しいインテグレーションを作成をクリック
  3. インテグレーション名を入力
  4. 設定するワークスペースを選択
  5. 保存をクリック
  6. 「インテグレーションが作成されました」と出るので、そのままインテグレーション設定をクリック
  7. 「内部インテグレーションシークレット」を「表示」し、控えておく。

notionhqをセットアップ

先ほど控えておいたシークレットとデータベースIDを.envファイルに書き込む

.env
NOTION_TOKEN="<シークレット>"
NOTION_DATABASE_ID="<データベースID>"

notionhqのクライアントを以下で初期化します。

src/libs/notion/notionAPI
import { Client } from '@notionhq/client';

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

notion-to-markdownをセットアップ

notion-to-markdownを以下で初期化します。

src/libs/notion/n2m.ts
import { NotionToMarkdown } from "notion-to-md";
import { notion } from "./notionAPI";

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

バックエンドの作成

ブログに必要なAPIを作成して行きます。

  • 全ての記事を取得するAPI
  • 一つの記事を取得するAPI
  • 記事の本文をマークダウン形式で取得するAPI
  • ハート(いいね)を追加するAPI (任意)
  • ハート(いいね)をキャンセルするAPI (任意)

全ての記事を取得

app/api/getAllPosts/route.ts
import { NextResponse } from 'next/server';
import { notion } from '@/app/libs/notion/notionAPI';

export async function GET() {
    try {
        const response = await notion.databases.query({
            database_id: process.env.NOTION_DATABASE_ID!,
        });

        const posts = response.results
        const postsProperties = posts.filter((post: any) => post.properties.published.checkbox)
            .map((post: any) => {
                const id = post.id
                const title = post.properties.title.title[0]?.plain_text;
                const publishdate = post.properties.publishdate.date?.start;
                const editdate = post.properties.editdate.date?.start;
                const tags = post.properties.tags.multi_select.map((item: any) => item.name);

                return { id, title, publishdate, editdate, tags, thumbnail, like };
            });

        return new NextResponse(JSON.stringify(postsProperties), {
            status: 200,
            headers: {
                'Content-Type': 'application/json',
                'Cache-Control': 'no-store'
            },
        });
    } catch (error) {
        console.error('データの取得に失敗しました:', error);

        // エラーレスポンスを返す
        return new NextResponse(
            JSON.stringify({ error: 'データの取得に失敗しました。' }),
            {
                status: 500,
                headers: {
                    'Content-Type': 'application/json',
                },
            }
        );
    }
}

一つの記事を取得

app/api/getPageInfo/route.ts
import { NextRequest, NextResponse } from 'next/server';
import { notion } from '@/app/libs/notion/notionAPI';

export async function GET(request: NextRequest) {
    try {
        const pageId = request.nextUrl.searchParams.get("pageId")
        if (!pageId) {
            throw new Error("pageIdパラメータが認識できませんでした。")
        }
        const post: any = await notion.pages.retrieve({ page_id: pageId })

        if (!(post.properties.published.checkbox === true)) {
            throw new Error("非公開記事にはアクセスできません。")
        }

        const postProperties = {
            id: post.id,
            title: post.properties.title.title[0]?.plain_text,
            publishdate: post.properties.publishdate.date?.start,
            editdate: post.properties.editdate.date?.start,
            tags: post.properties.tags.multi_select.map((item: any) => item.name),
            thumbnail: post.properties.thumbnail.files[0]?.file.url,
            like: post.properties.like.number
        }

        return new NextResponse(JSON.stringify(postProperties), {
            status: 200,
            headers: {
                'Content-Type': 'application/json',
                'Cache-Control': 'no-store'
            },
        });
    } catch (error) {
        console.error('データの取得に失敗しました:', error);

        return new NextResponse(
            JSON.stringify({ error: 'データの取得に失敗しました。' }),
            {
                status: 500,
                headers: {
                    'Content-Type': 'application/json',
                },
            }
        );
    }
}

記事の本文をマークダウン形式で取得

src/app/api/getPageContent/route.ts
import { NextRequest, NextResponse } from 'next/server';
import { n2m } from '@/app/libs/notion/n2m';
import { notion } from '@/app/libs/notion/notionAPI';

export async function GET(request: NextRequest) {
    try {
        const pageId = request.nextUrl.searchParams.get("pageId")
        if (!pageId) {
            throw new Error("pageIdパラメータが認識できませんでした。")
        }

        const post: any = await notion.pages.retrieve({ page_id: pageId })
        if (!(post.properties.published.checkbox === true)) {
            throw new Error("非公開記事にはアクセスできません。")
        }

        const mdBlocks = await n2m.pageToMarkdown(pageId)

        return new NextResponse(JSON.stringify(mdBlocks), {
            status: 200,
            headers: {
                'Content-Type': 'application/json',
                'Cache-Control': 'no-store'
            },
        });
    } catch (error) {
        console.error('データの取得に失敗しました:', error);

        // エラーレスポンスを返す
        return new NextResponse(
            JSON.stringify({ error: 'データの取得に失敗しました。' }),
            {
                status: 500,
                headers: {
                    'Content-Type': 'application/json',
                },
            }
        );
    }
}

ハートの追加 (任意)

src/app/api/addLike/route.ts
import { NextRequest, NextResponse } from 'next/server';
import { notion } from '@/app/libs/notion/notionAPI';

export async function GET(request: NextRequest) {
    try {
        const pageId = request.nextUrl.searchParams.get("pageId")
        if (!pageId) {
            throw new Error("pageIdパラメータが認識できませんでした。")
        }
        const post: any = await notion.pages.retrieve({ page_id: pageId })

        if (!(post.properties.published.checkbox === true)) {
            throw new Error("非公開記事にはアクセスできません。")
        }

        const currentLike = post.properties.like.number

        await notion.pages.update({
            page_id: pageId,
            properties: {
                like: (currentLike ?? 0) + 1
            }
        })

        return new NextResponse("Success", {
            status: 200,
        });
    } catch (error) {
        console.error('データの取得に失敗しました:', error);

        return new NextResponse(
            JSON.stringify({ error: 'データの取得に失敗しました。' }),
            {
                status: 500,
                headers: {
                    'Content-Type': 'application/json',
                },
            }
        );
    }
}

ハートのキャンセル (任意)

src/app/api/cancelLike/route.ts
import { NextRequest, NextResponse } from 'next/server';
import { notion } from '@/app/libs/notion/notionAPI';

export async function GET(request: NextRequest) {
    try {
        const pageId = request.nextUrl.searchParams.get("pageId")
        if (!pageId) {
            throw new Error("pageIdパラメータが認識できませんでした。")
        }
        const post: any = await notion.pages.retrieve({ page_id: pageId })

        if (!(post.properties.published.checkbox === true)) {
            throw new Error("非公開記事にはアクセスできません。")
        }

        const currentLike = post.properties.like.number

        await notion.pages.update({
            page_id: pageId,
            properties: {
                like: Math.max((currentLike ?? 0) - 1, 0)
            }
        })

        return new NextResponse("Success", {
            status: 200,
        });
    } catch (error) {
        console.error('データの取得に失敗しました:', error);

        return new NextResponse(
            JSON.stringify({ error: 'データの取得に失敗しました。' }),
            {
                status: 500,
                headers: {
                    'Content-Type': 'application/json',
                },
            }
        );
    }
}

フロントエンド側のセットアップ

バックエンドとの通信を行う処理を書いて行きます。

バックエンドから取得したデータを扱いやすいように型を定義しておきます

src/types/NotionPost.ts
export interface NotionPost {
    id: string;
    title: string;
    publishdate: string;
    editdate?: string;
    tags: string[];
    thumbnail?: string;
    like?: number
};

また、fetchを行う際、フルのURLを記述する必要があるようなので、.envと.env.developmentにそれぞれ本番環境と開発環境のURL(https://からドメインまで)を書いておきます。

.env
NEXT_PUBLIC_URL="<本番環境のURL>"
.env.development
NEXT_PUBLIC_URL="<開発環境のURL>"

全ての記事を取得

src/libs/blogs/getAllPosts.ts
import type { NotionPost } from "@/types/NotionPost";

export const getAllPosts: () => Promise<NotionPost[] | undefined> = async () => {
    try {
        const response = await fetch(`${process.env.NEXT_PUBLIC_URL}/api/getAllPosts`, { next: { revalidate: 60 } });
        const data = await response.json();
        if (!Array.isArray(data)) {
            throw new Error(`配列ではありません。: ${JSON.stringify(data, null, 2)}`)
        }
        return data
    } catch (error) {
        console.error('データの取得に失敗しました:', error);
    }
};

一つの記事を取得

src/libs/blogs/getPageInfo.ts
import { NotionPost } from "@/types/NotionPost";

export const getPageInfo: (pageId: string) => Promise<NotionPost | undefined> = async (pageId: string) => {
    try {
        const response = await fetch(`${process.env.NEXT_PUBLIC_URL}/api/getPageInfo?pageId=${pageId}`, { cache: "no-cache" });
        const data = await response.json();
        return data
    } catch (error) {
        console.error('データの取得に失敗しました:', error);
    }
};

記事の本文をマークダウン形式で取得

src/libs/blogs/getPageContent.ts
import type { MdBlock } from "notion-to-md/build/types";

export const getPageContent: (pageId: string) => Promise<MdBlock[] | undefined> = async (pageId: string) => {
    try {
        const response = await fetch(`${process.env.NEXT_PUBLIC_URL}/api/getPageContent?pageId=${pageId}`, { cache: "no-cache" });
        const data = await response.json();
        return data
    } catch (error) {
        console.error('データの取得に失敗しました:', error);
    }
};

ハートの追加 (任意)

src/libs/blogs/addLike.ts
export const addLike: (pageId: string) => Promise<void> = async (pageId: string) => {
    try {
        await fetch(`${process.env.NEXT_PUBLIC_URL}/api/addLike?pageId=${pageId}`);
    } catch (error) {
        console.error('データの取得に失敗しました:', error);
    }
};

ハートのキャンセル (任意)

src/libs/blogs/cancelLike.ts
export const cancelLike: (pageId: string) => Promise<void> = async (pageId: string) => {
    try {
        await fetch(`${process.env.NEXT_PUBLIC_URL}/api/cancelLike?pageId=${pageId}`);
    } catch (error) {
        console.error('データの取得に失敗しました:', error);
    }
};

詰まったこと

本番環境でのみ、Notionで更新したデータがデプロイを行うまで反映されませんでした。
結果として、Next.jsのHTML生成方法である 「SSG」 になっていたからでした。
SSGはビルド時にのみHTMLを生成し、その後はfetchを行いません。
「ISR」 というHTML生成方法に変更するとfetchのみは行うようになるようです。
fetchの引数、APIレスポンスを以下のように設定すると反映されるようになりました。

APIレスポンス

'Cache-Control': 'no-store'
return new NextResponse(XXX, {
    status: 200,
    headers: {
        'Content-Type': 'application/json',
        'Cache-Control': 'no-store'
    },
});

fetch

{ cache: "no-cache" }
//もしくは以下で間隔を指定
{ next: { revalidate: 60 } }
fetch("XXX", { cache: "no-cache" });
fetch("XXX", { next: { revalidate: 60 } });
1

Discussion

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