- Notion をCMSとして使い、Astro で静的サイトを生成する仕組み
-
.env/.dev.varsなど環境変数まわりの正しい設定方法 - Node.js のバージョン管理でハマったポイントと解決策
- Notion API のバージョン差分で注意すべき点
もともと Xserver のレンタルサーバーで WordPress のブログ(生活を豊かにするガジェットブログ)を運用していましたが、更新が1年近く止まる間も収益は下がる一方で、サーバー費用という固定費だけがかかり続け、維持が難しくなっていきました。
加えてブロックエディタでの執筆や画像アップロード・リサイズの手間も煩わしく、下書きに使っていた Notion をそのままCMSにできないかと考えるようになりました。
そこで Xserver Static の登場をきっかけに静的ホスティングへの移行を検討し、無料で使い慣れた Notion をデータベース、Cloudflare Pages を配信先にする構成に切り替えました。
otoyo/astro-notion-blog というオープンソーステンプレートのおかげで Notion と Astro をつなぐだけでこの構成を実現でき、ドメイン代以外はほぼ無料で運用できています。
Astro と Notion API を組み合わせると、執筆は使い慣れた Notion で行いつつ、サイトは静的サイト生成(SSG)による高速なページとして配信できます。
管理画面を自前で作らずに Notion を入稿インターフェースとして使えるのが、このテンプレートを選んだ一番の理由です。
この記事では、実際に Astro × Notion でこのブログをSSG構築した際の仕組みと、途中でつまずいたポイントを実体験ベースで書き残します。同じ構成でブログを作りたい方や、WordPressからの移行を検討している方の参考になれば幸いです。
仕組み: Notion をデータソースにした記事取得
テンプレートは Notion 公式の @notionhq/client を使い、ビルド時に Notion のデータベースへ問い合わせて記事一覧を取得する。
記事一覧の取得とフィルタリング
中心になるのが src/lib/notion/client.ts の getAllPosts() で、Published チェックボックスが true かつ Date が現在時刻以前の行だけを絞り込み、Date の降順で全件をページネーションしながら取得している。
const params: requestParams.QueryDataSource = {
data_source_id: dataSouceId,
filter: {
and: [
{
property: 'Published',
checkbox: {
equals: true,
},
},
{
property: 'Date',
date: {
on_or_before: new Date().toISOString(),
},
},
],
},
sorts: [
{
property: 'Date',
direction: 'descending',
},
],
page_size: 100,
} ビルド時の静的ページ生成
取得した Post[] は getStaticPaths() に渡され、Astro が記事ごとの静的ページを事前生成する。記事詳細ページ src/pages/post/[slug].astro 側はこうなっている。
export async function getStaticPaths() {
const posts = await getAllPosts()
return posts.map((post: interfaces.Post) => ({ params: { slug: post.Slug } }))
} つまり「Notion のデータベースの行 = ブログの1記事」で、ビルドのたびに Notion から最新の内容を取ってきて静的サイトに落とし込む、という仕組みになっている。
動的なサーバーサイドレンダリングではないので、記事を更新したら再デプロイが必要になる点は最初に理解しておく必要があります。
環境変数とビルド設定の工夫
Notion 連携に必要なのは NOTION_API_SECRET(Integration のトークン)と DATABASE_ID(複製したデータベースの ID)の2つだけで、src/server-constants.ts で import.meta.env と process.env の両方から読めるようにフォールバックが組まれている。
export const NOTION_API_SECRET =
import.meta.env.NOTION_API_SECRET || process.env.NOTION_API_SECRET || ''
export const DATABASE_ID =
import.meta.env.DATABASE_ID || process.env.DATABASE_ID || '' 実際のセットアップ手順はこう進めた。
- Notion Developers で integration を作成し、発行される Internal Integration Token を
NOTION_API_SECRETとして控える - 記事管理用の Notion データベースを用意し、作成した integration をそのページに接続(共有)する
- 複製したデータベースのページ URL(
https://notion.so/your-account/<この部分>?v=xxxx)からDATABASE_IDを取得する - ローカルには
.envを作成し、NOTION_API_SECRETとDATABASE_IDを記入するNOTION_API_SECRET="secret_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx" DATABASE_ID="xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx" -
npm run devでトップページが正しく表示され、Notion 側のタイトルが反映されることを確認する - 本番の Cloudflare Pages 側では、プロジェクトの「環境変数」に同じ
NOTION_API_SECRET/DATABASE_ID(加えて後述のNODE_VERSION)を登録する
このブログでは Cloudflare Pages Functions(問い合わせフォームの API)も同居させているため、astro.config.mjs 側にもひと工夫入っている。
Astro の integration(CoverImageDownloader など)が内部で src/lib/notion/client.ts を読み込み、モジュール読み込み時点で Notion クライアントを生成してしまう都合上、.env の読み込みが integration の import より先に完了している必要がある。
そのため astro.config.mjs の冒頭で loadEnv() を呼んで process.env に反映してから、integration 群を dynamic import する構成にしている。
const fileEnv = loadEnv(process.env.NODE_ENV ?? 'production', process.cwd(), '');
for (const [key, value] of Object.entries(fileEnv)) {
process.env[key] ??= value;
}
const [
{ default: CoverImageDownloader },
{ default: CustomIconDownloader },
{ default: FeaturedImageDownloader },
{ default: PublicNotionCopier },
] = await Promise.all([
import('./src/integrations/cover-image-downloader'),
import('./src/integrations/custom-icon-downloader'),
import('./src/integrations/featured-image-downloader'),
import('./src/integrations/public-notion-copier'),
]); Cloudflare Pages 本番環境では環境変数がこのファイルの実行前に注入されるため影響を受けないが、ローカルビルドで NOTION_API_SECRET を .env にしか書いていないと、integration の中で空文字列として扱われてしまう、という落とし穴を踏んでからこの対応に落ち着いた。
つまずいた点
.env と .dev.vars の二重管理
Astro(Vite ベースの npm run dev)が読むのは .env だが、Cloudflare Pages Functions(wrangler pages dev)が読むのは .dev.vars で、二つ管理しなくてはならなかったことです。
最初は .dev.vars だけを用意して npm run dev を動かしたところ、トップページが 500 エラーになり APIResponseError: API token is invalid. というログが出た。
トークン自体は curl <https://api.notion.com/v1/users/me> にそのまま渡すと 200 OK が返ってきたので、トークンの値が間違ってはいませんでした。
ここで切り分けられたのは「トークンの値」ではなく「そのトークンが Astro の実行時に読み込まれていない」ことが原因だとわかりました。
.dev.vars は wrangler 専用の仕組みで、Astro/Vite 側の npm run dev には読み込まれず NOTION_API_SECRET が空文字列のまま Notion クライアントが初期化されてしまいました。
そこで.env を新たに作って同じ値を書いたところ、npm run dev は問題なく動くようになりました。
ただ違う問題が発生し、今度は「同じ値を .env と .dev.vars の2ファイルに書き続ける必要がある」という問題が残りました。
.env を人間が直接編集する唯一のファイルと決め、.dev.vars はそこから npm run vars:sync(実体は scripts/vars-sync.cjs)で自動生成する運用に落ち着かせた。
生成された .dev.vars の先頭には「npm run vars:sync による自動生成なので手で編集しないこと」という警告コメントを自動で挿入し、誤って直接編集されるのを防いでいる。
ビルド時だけシェル環境変数が必要
npm run dev は Vite 経由で .env を自動で読むが、npm run build はビルド開始直後に Notion API を呼ぶ integration(カバー画像のダウンロードなど)があるため、シェル側の環境変数として NOTION_API_SECRET / DATABASE_ID が見えている必要がある。
ローカルでビルド確認するときは次のように明示的に export してから実行する必要があった。
set -a; source .env; set +a; npm run build Cloudflare Pages 上ではプロジェクト設定に環境変数を登録するのでこの制約は発生しないが、
ローカル特有の問題だったので修正しました。
Node のバージョン起因のネイティブビルド失敗
依存関係に含まれる re2(正規表現の高速化用ネイティブモジュール)が、ローカルのデフォルト Node バージョンでビルドに失敗してしまった。
Node のバージョンを一段階下げても、今度は ESLint 系パッケージが要求するバージョン帯(^20.19.0 || ^22.13.0 || >=24)に合わず EBADENGINE 警告が出る、という状況になった。
最終的にNode のバージョンを Volta で 22.13.1 に pin し、依存関係のインストールを警告・エラーなしで通した。
ただしその後 Volta 自体がサポート終了となったため、mise に管理を移行し、リポジトリ直下の mise.toml に固定バージョンをコミットする形にした。
[tools]
node = "22.22.0" 注意点として、Cloudflare Pages は mise を認識しないため、ダッシュボード側の NODE_VERSION は依然として手動で mise.toml と同じ値に揃え続ける必要があります。
Notion API のバージョンアップで data_source_id が必須に
@notionhq/client のバージョンによっては、データベースを database_id で直接クエリするのではなく、databases.retrieve() でデータベースを取得したあとに data_sources からデータソース ID を取り出し、dataSources.query() に渡す2段階の呼び出しが必要になります。
Notion 側のデータモデル変更に追従したものなので、テンプレートを更新する際は API のバージョン差分にも注意が必要です。
このブログ独自のカスタマイズ
テンプレートそのまま使うのではなく、これをベースにカスタマイズしています。
常に更新していますが、当初やったカスタマイズは以下になります。
- 記事 URL を
/posts/から/p/に短縮 - サイドバーの位置をデスクトップ表示で右側に変更
- Cloudflare Pages Functions + Resend + Cloudflare Turnstile を組み合わせた問い合わせフォームを追加
- Google AdSense をローカルビルドではスキップし、
CF_PAGES環境変数で本番のみ配信するよう分岐
これらはいずれも「テンプレートは土台として使いつつ、必要な部分だけ最小限に上書きする」という方針で進めた。
Notion 連携のコア部分(src/lib/notion/client.ts 周り)にはほぼ手を入れず、UI やインフラ寄りの部分だけをカスタマイズしているのがポイントになります。
今後もどんどんカスタマイズしていきますので、楽しみにしていてください!
よくある質問(FAQ)
Astro × Notion でブログをSSG化するメリットは?
記事の執筆・画像管理を Notion で完結できるため、自前の管理画面やブロックエディタを開発する必要がありません。
加えて静的サイト生成のため表示速度が速く、Cloudflare Pages なら無料枠内でサーバー代をほぼゼロに抑えられる点が大きなメリットです。
Notion で記事を更新したら、サイトにはすぐ反映されますか?
反映されません。
このテンプレートは動的なサーバーサイドレンダリングではなく、ビルド時に Notion のデータベースから記事を取得して静的ページを生成する仕組みのため、記事を更新・公開したあとは再デプロイが必要です。
Astro × Notion ブログの運用コストはどれくらいですか?
Notion・Astro・Cloudflare Pages はいずれも無料枠で利用できるため、実質的にかかるのはドメインの取得・維持費だけです。
アクセス数が増えて Cloudflare Pages の無料枠を超えない限り、追加コストはほとんど発生しません。
環境変数の設定でよくハマるポイントは?
npm run dev(Astro/Vite)が読む .env と、Cloudflare Pages Functions(wrangler)が読む .dev.vars は別ファイルのため、二重管理でつまずきやすいです。
.env を正として npm run vars:sync で .dev.vars を自動生成する運用にすると管理が楽になります。
また npm run build はビルド開始直後に Notion API へアクセスするため、シェル側の環境変数としても NOTION_API_SECRET と DATABASE_ID を読み込ませておく必要があります。
WordPress と何が違うの?
WordPress はサーバー上で PHP がデータベースに毎回問い合わせてページを生成する動的CMSのため、レンタルサーバー代などの固定費が継続的にかかり、プラグインの脆弱性対応やアップデート対応も必要です。
一方 Astro × Notion の構成はビルド時に静的HTMLを生成して配信するだけなので、サーバーの維持管理が不要で、Cloudflare Pages の無料枠内でほぼ無料運用できます。
執筆面では、WordPress の管理画面やブロックエディタの代わりに使い慣れた Notion をそのまま入稿インターフェースとして使える点も大きな違いです。
ただし記事を更新してもその場では反映されず、再デプロイ(ビルド)が必要になる点は WordPress との明確な違いとして押さえておく必要があります。
Astro って他のフレームワークとは何が違うの?
多くのフレームワーク(Next.js や Nuxt.js など)はページ全体に JavaScript(画面を動かすプログラム)を送ります。
一方でAstro は「Islands Architecture(アイランドアーキテクチャ)」という考え方で、基本は完成済みの HTML だけを送り、ボタンなど動きが必要な部分にだけ後から JavaScript を追加します。
これにより表示が速くなりやすく、React・Vue・Svelte などのコンポーネント(画面部品)を自由に組み合わせられるのも特徴です。
文章中心のブログとは相性がよく、このテンプレートでも採用されています。
まとめ
Astro と Notion API を組み合わせたブログ構築は、記事管理を Notion に任せつつ配信は静的サイトとして高速に行えるのが強みです。
WordPress のようなCMSの運用コストや執筆時の煩わしさに悩んでいる方には、有力な選択肢になるはずです。
しかし実際に手を動かすと「.env と .dev.vars の使い分け」「ビルド時と dev 時で環境変数の読み込み経路が違う」「Node.js のバージョン管理」「Notion API のバージョン差分」といった、テンプレートのドキュメントだけでは気づきにくい落とし穴がいくつかありました。
これから otoyo/astro-notion-blog をベースにブログを作る人は、まず Notion 連携部分をテンプレートのまま動かして環境変数まわりの挙動を理解してから、URL 構造やフォームなどのカスタマイズに進むと詰まりにくいはずです。
本ブログでも今後、カスタマイズの詳細や運用ノウハウを追記していく予定なので、続報もあわせてチェックしてみてください。
