自分のポートフォリオサイトや技術ブログを作る際、WordPressを導入するのはサイトが重くなりがちですし、自前でデータベースを設計してマークダウン対応のリッチテキストエディタをゼロから開発するのは大変です。

そこで今回は、「NotionをヘッドレスCMSとして利用し、Notion API経由で自作のPHPサイトに記事を表示する」 というアプローチ試してみました。


1. なぜ Notion API を選んだのか?

  1. Notionを日常的に使っている: 画像のドラッグ&ドロップ、シンタックスハイライト付きコードブロック、表など、技術ブログに必要な機能が揃っているもの良いです。
  2. マルチデバイス対応: PCでもスマホアプリでもサクサク記事を書いて公開できます。(スマホでは多分しない)
  3. バックエンド開発の圧倒的な削減: データベースのテーブル設計、ログイン・認証機能、画像ストレージの用意が一切不要

2. 前提条件と技術スタック

本チュートリアルは、以下の環境を前提としています。

ディレクトリ構成のイメージ

text

/var/www/html/
 ├─ .env                # APIキーなどを格納
 ├─ .htaccess           # URLリライトのルール
 ├─ composer.json       # vlucas/phpdotenv など
 ├─ vendor/             # Composerのライブラリ群
 ├─ cache/notion/       # APIのキャッシュ保存先(www-dataの書込権限必須)
 ├─ notion_client.php   # Notion API通信とHTMLパースを担うコアクラス
 ├─ project.php         # 記事の一覧を表示するページ
 └─ article.php         # 記事の詳細を表示するページ

3. Notion側の準備(データベースとAPIキー)

3.1 データベースの作成

Notion上でフルページの「データベース(テーブル)」を作成し、以下のプロパティ(列)を正確な名前で設定します(※大文字小文字区別あり)。

3.2 APIキーの取得と連携

  1. Notion My Integrations から新しいインテグレーションを作成し、Internal Integration Secret(APIキー) を取得
  2. 作成したデータベースURLから、データベースID(32桁の英数字)を控える。(例: notion.so/xxxx...xxxx?v=... の x の部分)
  3. 【重要】 データベース画面右上の「…」メニューから「コネクトの追加」を選び、作成したインテグレーションにアクセス権を付与。

4. サーバーの設定と環境変数

4.1 .env ファイルの作成

ドキュメントルートに .env ファイルを作成し、取得したキーを記述します。.env が既にある場合は追記。

env

NOTION_API_KEY=ntn_xxxxxxxxxxxxxxxxxxxxxxxxxxx
NOTION_DATABASE_ID=xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
パーミッションの注意.env を手動編集した際は、Apacheが読み込めるように sudo chown www-data:www-data .env および sudo chmod 600 .env を実行する

4.2 .htaccess によるURLリライト

https://example.com/article.php?slug=my-post というURLはわかりにくいので、今回はhttps://example.com/article/my-post のようなURLスラッグを採用します。

apache

RewriteEngine on
# /article/xxxx にアクセスが来たら article.php?slug=xxxx に内部ルーティング
RewriteRule ^article/([a-zA-Z0-9_-]+)$ article.php?slug=$1 [L]

# .envファイルへの直接アクセスを遮断(セキュリティ対策)
<Files .env>
    order allow,deny
    Deny from all
</Files>

5. Notion API クライアントの実装

Notion APIと通信し、JSONをHTMLに変換し、さらにキャッシュまで行う notion_client.php は以下の通り(AIすごい)。

ここでは「再帰的な子ブロック(インデント)の取得」「HTMLパース」が記述されている。

php

<?php
classNotionClient {
private$apiKey;
private$databaseId;
private$cacheDir;
private$cacheTtl;

publicfunction__construct($apiKey,$databaseId,$cacheDir =null,$cacheTtl =60) {
$this->apiKey =$apiKey;
$this->databaseId =$databaseId;
$this->cacheDir =$cacheDir ?:__DIR__.'/cache/notion';
$this->cacheTtl =$cacheTtl;// キャッシュは60秒

if (!is_dir($this->cacheDir)) {
mkdir($this->cacheDir,0755,true);
        }
    }

// --- APIリクエスト用共通メソッド ---
privatefunctionrequest($method,$endpoint,$body =null) {
$url ="https://api.notion.com/v1/{$endpoint}";
$ch =curl_init();
curl_setopt($ch, CURLOPT_URL,$url);
curl_setopt($ch, CURLOPT_HTTPHEADER, [
'Authorization: Bearer '.$this->apiKey,
'Notion-Version: 2022-06-28',
'Content-Type: application/json',
        ]);
curl_setopt($ch, CURLOPT_RETURNTRANSFER,true);
if ($method ==='POST') {
curl_setopt($ch, CURLOPT_POST,true);
if ($body)curl_setopt($ch, CURLOPT_POSTFIELDS,json_encode($body));
        }
$response =curl_exec($ch);
curl_close($ch);
returnjson_decode($response,true);
    }

// --- ① 記事一覧の取得 ---
publicfunctiongetPublishedArticles() {
$cacheKey ='articles_list';
if ($cached =$this->getCache($cacheKey))return$cached;

$body = [
'filter' => [
'property' =>'Published',
'checkbox' => ['equals' =>true]
            ],
'sorts' => [['property' =>'PublishedDate','direction' =>'descending']]
        ];

$res =$this->request('POST',"databases/{$this->databaseId}/query",$body);
$articles = [];
if (isset($res['results'])) {
foreach ($res['results'] as$page) {
$articles[] =$this->parsePageProperties($page);
            }
        }
$this->setCache($cacheKey,$articles);
return$articles;
    }

// --- ② 記事詳細(ブロック)の取得 ---
publicfunctiongetArticleBySlug($slug) {
$cacheKey ='article_'.$slug;
if ($cached =$this->getCache($cacheKey))return$cached;

$body = [
'filter' => [
'and' => [
                    ['property' =>'Slug','rich_text' => ['equals' =>$slug]],
                    ['property' =>'Published','checkbox' => ['equals' =>true]]
                ]
            ]
        ];

$res =$this->request('POST',"databases/{$this->databaseId}/query",$body);
if (empty($res['results']))returnnull;

$page =$res['results'][0];
$article =$this->parsePageProperties($page);

// 本文(ブロック配列)を取得
$article['content'] =$this->getPageBlocks($page['id']);

$this->setCache($cacheKey,$article);
return$article;
    }

// --- 【ハマりポイント解決】子ブロックの再帰的取得 ---
privatefunctiongetPageBlocks($blockId) {
$blocks = [];
$cursor =null;
do {
$url ="blocks/{$blockId}/children?page_size=100". ($cursor ?"&start_cursor={$cursor}" :"");
$res =$this->request('GET',$url);
if (!isset($res['results']))break;

foreach ($res['results'] as$block) {
// インデントされた子ブロックがあれば再帰的にAPIを叩いて取得
if (!empty($block['has_children'])) {
$block['children_blocks'] =$this->getPageBlocks($block['id']);
                }
$blocks[] =$block;
            }
$cursor =$res['has_more'] ?$res['next_cursor'] :null;
        }while ($cursor);

return$blocks;
    }

// --- プロパティのパース(省略・抜粋) ---
privatefunctionparsePageProperties($page) {
$p =$page['properties'];
return [
'title' =>$p['Title']['title'][0]['plain_text'] ??'',
'slug' =>$p['Slug']['rich_text'][0]['plain_text'] ??'',
'description' =>$p['Description']['rich_text'][0]['plain_text'] ??'',
'tags' =>array_map(fn($t) =>$t['name'],$p['Tags']['multi_select'] ?? []),
'published_date' =>$p['PublishedDate']['date']['start'] ??null,
'cover' =>$page['cover']['external']['url'] ?? ($page['cover']['file']['url'] ??null),
        ];
    }

// --- ③ HTML への変換パーサー ---
publicfunctionblocksToHtml($blocks) {
$html ='';
$listOpen =false;$listType ='';

foreach ($blocks as$block) {
$type =$block['type'];

// リストタグの開閉制御
if ($type !=='bulleted_list_item' &&$type !=='numbered_list_item') {
if ($listOpen) {$html.= ($listType ==='ul') ?'</ul>' :'</ol>';$listOpen =false; }
            }

// 子ブロックのHTML化
$childrenHtml ='';
if (!empty($block['children_blocks'])) {
$childContent =$this->blocksToHtml($block['children_blocks']);
// リスト以外の子要素は左マージンでインデントを表現
if ($type !=='bulleted_list_item' &&$type !=='numbered_list_item') {
$childrenHtml ='<div style="margin-left: 1.5em;">'.$childContent.'</div>';
                }else {
$childrenHtml =$childContent;// リストのネストはそのまま結合
                }
            }

switch ($type) {
case'paragraph':
$text =$this->richTextToHtml($block['paragraph']['rich_text']);
$html.="<p>{$text}</p>{$childrenHtml}";
break;
case'heading_2':
$text =$this->richTextToHtml($block['heading_2']['rich_text']);
$html.="<h2>{$text}</h2>{$childrenHtml}";
break;
case'bulleted_list_item':
if (!$listOpen) {$html.='<ul>';$listOpen =true;$listType ='ul'; }
$text =$this->richTextToHtml($block['bulleted_list_item']['rich_text']);
$html.="<li>{$text}{$childrenHtml}</li>";
break;
case'code':
$text =implode('',array_map(fn($t) =>$t['plain_text'],$block['code']['rich_text']));
$lang =$block['code']['language'] ??'';
$html.="<pre><code class=\"language-{$lang}\">".htmlspecialchars($text)."</code></pre>{$childrenHtml}";
break;
case'image':
$url =$block['image']['file']['url'] ?? ($block['image']['external']['url'] ??'');
if ($url)$html.="<img src=\"{$url}\" style=\"max-width:100%; border-radius:8px;\">{$childrenHtml}";
break;
// ※ 他のブロック(quote, callout, divider等)も同様に記述
            }
        }
if ($listOpen)$html.= ($listType ==='ul') ?'</ul>' :'</ol>';
return$html;
    }

privatefunctionrichTextToHtml($richText) {
$html ='';
foreach ($richText as$text) {
$content =htmlspecialchars($text['plain_text']);
$ann =$text['annotations'] ?? [];
if (!empty($ann['bold']))$content ="<strong>{$content}</strong>";
if (!empty($ann['code']))$content ="<code>{$content}</code>";
if (!empty($text['href']))$content ="<a href=\"{$text['href']}\">{$content}</a>";
$html.=$content;
        }
return$html;
    }

// --- キャッシュ制御 ---
privatefunctiongetCache($key) {
$file =$this->cacheDir.'/'.md5($key).'.json';
if (!file_exists($file))returnnull;
if (time() -filemtime($file) >$this->cacheTtl) {unlink($file);returnnull; }
returnjson_decode(file_get_contents($file),true);
    }

privatefunctionsetCache($key,$data) {
$file =$this->cacheDir.'/'.md5($key).'.json';
file_put_contents($file,json_encode($data, JSON_UNESCAPED_UNICODE));
    }
}
?>

6. フロントエンド(表示用ページ)の作成

記事一覧ページ (project.php など)

一覧を取得してループで回す(AIが書いた)

php

<?php
require_once'vendor/autoload.php';
$dotenv = Dotenv\Dotenv::createImmutable(__DIR__);
$dotenv->load();

require_once'notion_client.php';
$notion =newNotionClient($_ENV['NOTION_API_KEY'],$_ENV['NOTION_DATABASE_ID']);
$articles =$notion->getPublishedArticles();
?>

<!-- HTML部分 -->
<divclass="blog-list">
<?phpforeach ($articles as$article):?>
<ahref="/article/<?phpechohtmlspecialchars($article['slug']);?>">
<h2><?phpechohtmlspecialchars($article['title']);?></h2>
<p><?phpechohtmlspecialchars($article['description']);?></p>
<time><?phpechodate('Y-m-d',strtotime($article['published_date']));?></time>
</a>
<?phpendforeach;?>
</div>

記事詳細ページ (article.php)

.htaccess の設定により、URLのスラッグが $_GET['slug'] として渡ってきます。(AIで書いた)

php

<?php
require_once'vendor/autoload.php';
$dotenv = Dotenv\Dotenv::createImmutable(__DIR__);
$dotenv->load();
require_once'notion_client.php';

$slug =$_GET['slug'] ??'';
if (empty($slug)) {header('Location: /');exit; }

$notion =newNotionClient($_ENV['NOTION_API_KEY'],$_ENV['NOTION_DATABASE_ID']);
$article =$notion->getArticleBySlug($slug);

if (!$article) {
http_response_code(404);
echo"<h1>404 Not Found</h1>";
exit;
}

$contentHtml =$notion->blocksToHtml($article['content']);
?>

<!DOCTYPEhtml>
<html>
<head>
<title><?phpechohtmlspecialchars($article['title']);?></title>
<!-- Prism.js のCSS (シンタックスハイライト用) -->
<linkhref="https://cdnjs.cloudflare.com/ajax/libs/prism/1.29.0/themes/prism-tomorrow.min.css"rel="stylesheet">
</head>
<body>
<h1><?phpechohtmlspecialchars($article['title']);?></h1>
<divclass="article-body">
<?phpecho$contentHtml;?>
</div>

<!-- Prism.js のJS -->
<scriptsrc="https://cdnjs.cloudflare.com/ajax/libs/prism/1.29.0/prism.min.js"></script>
<scriptsrc="https://cdnjs.cloudflare.com/ajax/libs/prism/1.29.0/components/prism-php.min.js"></script>
</body>
</html>

7. まとめ

以上の手順で、「Notionで記事を書いたら1分後(キャッシュの寿命後)には自分のサイトに反映される」 というブログ環境が完成します。

Notion APIは構造が複雑(特に子ブロックの取得周り)ですが、一度PHP側のクライアントクラスを作ってしまえば、あとはNotion側で記事を書くだけで全てが自動化されます。

皆さんもぜひ、自分のポートフォリオサイトにNotion CMSを導入してみてはどうでしょうか!