投稿を捗らせる Next.js+Notion のブログ作り
技術選定
ブログ記事の執筆が捗るように以下のような基準で技術選定を行いました。
技術選定の基準
- デザインの自由度が高い
- 汎用性のある有名どころのフレームワークを利用する
- ドキュメント管理ツールや CMS (Content Management Service) を利用する
フレームワーク: Next.js (on Vercel)
App Router の導入により、幾分か物議を醸した Next.js ですが、なんだかんだ言って人気のフレームワークであり、業務で触っていたこともあって、こちらを利用することにしました。
デプロイ先としては Next.js と相性の良い Vercel を採用しました。個人利用の場合は無料かつ、Github 連携やカスタムドメイン設定なども簡単にできるので良いです。
スタイリング: shadcn/ui + tailwindcss
スタイリングには mui のような UI ライブラリを利用するのが多いですが、最近の主流はヘッドレス UI ライブラリ、とのことです。ざっくばらんに言うと、ヘッドレス UI ライブラリは従来の UI ライブラリのように npm Registry に登録されているコンポーネントをそのまま使うのではなく、必要なベースコンポーネントをコピペし、用途に合わせてカスタマイズする、というような物です。独自フレームワークで独自仕様の理解に時間がかかるのと同様、すでに出来上がっている UI ライブラリの仕様を理解するのは大変ですし、細かなカスタマイズは難しいです。
Vercel が v0 と合わせて出した shadcn/ui が Next.js との相性も良かったことや界隈で注目されていたこともあって利用することにしました。
Shadcn 自体が tailwindcss を利用してスタイリングをしているのもあり、その他細かいスタイリングは tailwindcss を利用することにしました。
CMS: Notion
業務でもプライベートでもドキュメントを作成する際には Confluence や Notion などのドキュメント管理ツールを使って作成しており、それを利用して書く方がよりコンテンツに集中し、かつ簡単に様々な見た目で書けると思いました。少し調べてみると Notion が API を提供していることから、Notion をヘッドレス CMS として利用しているサンプルが多かったため、Notion をヘッドレス CMS として利用することにしました。ただし、先ほどの独自フレームワークの話と同様の理由で、また学習の意味も兼ねて、サンプルは参考にしつつもスクラッチで作成することにしました。
Notion をヘッドレス CMS として利用するにあたっての課題
Notion ページのスタイリング
Notion の公式 API では Notion ページはブロックで返却されるため、それを HTML およびスタイルに変換する必要があります。
ただ、ブロックを一つ一つ定義していくのは「CSS 完全に理解した」自分としてはかなりきついものがありました。いろいろ探したところ、Notion の非公式 API を利用した react-notion-x という Notion のレンダリングクライアントを見つけ、利用することにしました。
プライベート DB での画像の対応
react-notion-x では Notion のプライベートページも利用できますが、画像の取り扱いに若干工夫が必要です。と言うのも、Notion にアップロードした画像は期限付き URL として返却されるのですが、プライベートページ上の画像だと、アクセス制限にかかってしまい、画像を表示することができません。
画像を表示させるためには、 react-notion-x のクライアント作成時に利用するクレデンシャル(token_v2
と notion_user_id
)をヘッダーに付与する必要があります。そこで、そのロジックを API Route に記述し、画像を取得する際には、元の URL とブロック ID をクエリパラメータとして API Route に問い合わせるように修正しました。
import { NextRequest, NextResponse } from "next/server";
export async function GET(req: NextRequest) {
const { searchParams } = new URL(req.url);
const url = searchParams.get("url");
const blockId = searchParams.get("blockId");
if (!url) {
return NextResponse.json({ error: "Missing url" }, { status: 400 });
}
try {
let res;
if (url.includes("s3")) {
res = await fetch(
`https://www.notion.so/image/${encodeURIComponent(
url
)}?table=block&id=${blockId}&userId=${
process.env.NOTION_ACTIVE_USER
}&cache=v2`,
{
headers: {
// S3 側でCookieが必要な場合はCookieヘッダを付与
cookie: `token_v2=${process.env.NOTION_TOKEN_V2}; Secure; HttpOnly;`,
accept: "*/*",
},
}
);
} else {
res = await fetch(url, {
headers: {
accept: "*/*",
},
});
}
if (!res.ok) {
return new NextResponse(res.body, { status: res.status });
}
// バイナリをそのまま返却
const contentType = res.headers.get("content-type") || "image/jpeg";
const arrayBuffer = await res.arrayBuffer();
return new NextResponse(arrayBuffer, {
status: 200,
headers: {
"Content-Type": contentType,
},
});
} catch (error) {
return NextResponse.json({ error: String(error) }, { status: 500 });
}
}
"use client";
import { useTheme } from "next-themes";
import dynamic from "next/dynamic";
import { type ExtendedRecordMap } from "notion-types";
import { NotionRenderer } from "react-notion-x";
const Code = dynamic(() =>
import("react-notion-x/build/third-party/code").then((m) => m.Code)
);
const Equation = dynamic(() =>
import("react-notion-x/build/third-party/equation").then((m) => m.Equation)
);
const Pdf = dynamic(
() => import("react-notion-x/build/third-party/pdf").then((m) => m.Pdf),
{
ssr: false,
}
);
const Modal = dynamic(
() => import("react-notion-x/build/third-party/modal").then((m) => m.Modal),
{
ssr: false,
}
);
export function NotionPage({
recordMap,
rootPageId,
}: {
recordMap: ExtendedRecordMap;
rootPageId: string;
}) {
const { resolvedTheme } = useTheme();
if (!recordMap || !resolvedTheme) {
return null;
}
return (
<NotionRenderer
recordMap={recordMap}
fullPage={true}
darkMode={resolvedTheme === "dark"}
showTableOfContents={true}
rootPageId={rootPageId}
mapImageUrl={(url, block) =>
url
? `/api/notion-image?url=${encodeURIComponent(url)}&blockId=${
block.id
}`
: ""
}
disableHeader={true}
components={{ Code, Equation, Pdf, Modal }}
/>
);
}
なお、取得される URL は期限付きですが、Vercel 上の Next.js では ISR (Incremental Static Regeneration) が利用できるため無問題です。
Notion ページのスタイル微調整
react-notion-x 公式では以下のように css ファイルを読み込むことでスタイルの適用を指示されています。
// core styles shared by all of react-notion-x (required)
import "react-notion-x/src/styles.css";
ただし、直接読み込む方法ではスタイルの微調整ができないので、この CSS ファイルを手元に notion.css
などで保存し、CSS を直接いじるようにしています。対象のクラスのほとんどは notion-
のプレフィックスがついているので、既存の他スタイルには影響はないはずです。
Discussion