📓
Notion + Next.jsでブログサイトを作ってみた
はじめに
NotionをヘッドレスCMS代わりとするブログサイトを作ってみます。
これを基に作ったのがこちら
参考にさせていただいた記事
環境構築
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側のセットアップ
- Notionの任意のページに以下のようなプロパティのデータベースを作成
必要ないプロパティは適宜削除してください
- 「テーブルビュー」などをクリックし、ビューのリンクをコピーをクリック
- コピーしたリンクの以下の部分がデータベースIDになるので、控えておきます。
https://www.notion.so/<データベースID>?v=XXXXX
NotionAPIをセットアップ
- NotionAPIインテグレーションの作成ページにアクセス
https://www.notion.so/my-integrations - 新しいインテグレーションを作成をクリック
- インテグレーション名を入力
- 設定するワークスペースを選択
- 保存をクリック
- 「インテグレーションが作成されました」と出るので、そのままインテグレーション設定をクリック
- 「内部インテグレーションシークレット」を「表示」し、控えておく。
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 } });
Discussion