--- title: "PHP 200 ファイル超のサイトを、止めずに Next.js へ丸ごと入れ替えた話(バカ)" description: "素の PHP 223 ファイルで動いていた個人サイトを、止めずに Next.js へ全面移行した記録。Apache の転送ルールで 1 パスずつ移す strangler 方式、本番 DB のコピー 2 つに同じ操作を流す突き合わせ、深夜の切替失敗と 5 分ロールバック、Cloudflare Fonts が hydration を壊す話まで。コード量・応答速度・HTML サイズは実測を載せます。速い面と遅くなった面の両方が出たので、そのまま書きます。" url: https://ryuuneko.com/blog/php-to-nextjs-full-migration published: 2026-09-12 05:49:34.020308 updated: 2026-09-12 05:49:34.020308 --- このサイト(ryuuneko.com)は、2026 年 9 月 12 日の深夜 2 時 12 分から、PHP で動いていません。 それまでは素の PHP でした。フレームワーク無し、Composer も無し、`includes/config.php` を読んで `header.php` と `footer.php` で挟む、昔ながらの作りです。ブログも管理画面も、読み上げ Bot の状態ページも、ドット絵ツールの枠も、部員証も、ぜんぶ PHP。数えたら 223 ファイルありました。 これを Next.js に**全部**置き換えました。しかも、サイトを止めずに。最初に言っておくと、タイトルの「(バカ)」は自分に向けたものです。やった後で「別に PHP のままでよかったのでは」と思う瞬間が何度もありました。この記事では、なぜやったのか、どう入れ替えたのか、何が壊れたのか、を順に書きます。 --- ## 最初は「ブログだけ」だった きっかけは小さくて、ブログ記事の一覧が遅いことでした。記事ごとにタグを別々に問い合わせる書き方(いわゆる N+1)になっていて、記事が増えるほど遅くなる。直すなら、ついでにブログだけ Next.js に載せ替えて、静的生成と再検証の仕組みを使いたい。そう思って「ブログだけ」の計画を立てました。 計画の段階で、AI(Claude)に「既存より良くなる条件」を出させました。N+1 を無くす、データベースが落ちても直前の値を返す、HTML の組み立てを型で守る、記事更新時に該当ページだけ再生成する。この 4 つが満たせないなら移行しない、という縛りです。 ブログが動いた時点で、「他のページも同じ枠組みで書けるな」と思ってしまった。これが分岐点でした。**「PHP を全部やめたい」に方針を広げた**のは自分の判断で、ここから先は誰のせいにもできません。 --- ## 「止めずに」の仕掛け — Apache の転送で 1 パスずつ移す 一気に切り替えるのではなく、**Apache の前に Next を置かず、Apache に「このパスは Next に渡して」と教える**方式にしました。Apache の `.htaccess` に転送ルール(`[P]`、プロキシ)を書き、移行できたパスから順に Next へ流す。移行していないパスは今までどおり PHP が応答する。 こうすると、 - 移行は 1 パスずつ進められる - 戻すときは `.htaccess` の該当行をコメントアウトするだけ - 利用者からは何も変わらない(URL も見た目も同じ) という利点があります。専門的には strangler fig(絞め殺しイチジク)パターンと呼ばれます。宿主の木を外側から包んで育ち、最後には木がなくなってもイチジクだけで立っている、あの木です。既存のシステムの外側に新しいシステムを少しずつ被せていき、全部被さったら古い方を止める。 ![Cloudflare → VPS → 自宅の Apache が入口のまま、.htaccess の転送ルールでパスごとに PHP か Next かを振り分ける図](/blog/uploads/php-to-nextjs-strangler.svg) *▲ 入口の Apache は最後まで残し、移行済みのパスだけ Next に転送する。切替は転送ルールの追加、戻すのはコメントアウト。* --- ## 「同じ」をどう確かめるか 置き換える以上、**PHP と同じ応答**でなければ意味がありません。見た目が同じ、では足りない。管理画面の「記事を保存」を押したとき、データベースに入る中身まで同じでなければ、利用者が気づかないところで壊れます。 なので、本番のデータベースを 2 つコピーして、片方に PHP、もう片方に Next を繋ぎ、**同じ操作列を両方に流して、応答と最終的なデータベースの状態を比較する**道具を作りました。部員証で 162 ステップ、CMS で 133、問い合わせと画廊で 80、配信予定で 39、Bot の状態 API で 90。書き込み系も全部含みます。 比較で見つかった差は、Next 側の間違いだけではありませんでした。**PHP 側のバグも 4 つ出ました**。記事のエクスポートが動いていない(必要な拡張が入っていない)、テンプレート更新の失敗が 500 になる、辞書追加の API に到達できない経路、など。何年も動いていたコードの「動いていなかった部分」が、比較して初めて見える。移行そのものより、この突き合わせの方が価値があったと今は思っています。 --- ## ついでに nonce CSP を入れ、onclick を 366 箇所消した PHP 時代、このサイトには CSP(Content Security Policy、ブラウザに「この script 以外は実行するな」と伝える仕組み)を入れていませんでした。理由は、HTML の中に `onclick="…"` のような属性ハンドラが 285 箇所あって、CSP と根本的に相性が悪かったからです。「緩い CSP を入れても飾りになる」と判断して見送っていた。 Next 移行で HTML を全部自前で組めるようになったので、前提が変わりました。リクエストごとに使い捨ての乱数(nonce)を発行し、その nonce が付いた script だけ許可する。属性ハンドラは `data-call="関数名"` に置き換えて、1 つの委譲スクリプトが受ける。置換した数は、テンプレート側と JS 側を合わせて 366 箇所。 「利用者の体験が変わらないなら、ガッツリ改修していい」と決めて、テーマ切替、モバイルナビ、画廊のいいね、管理画面のドラッグ並べ替えなど 31 の操作を自動で叩いて、CSP 違反 0 を確認してから本番に持っていきました。 --- ## 切替 1 回目は 5 分で戻した — DirectoryIndex と子 .htaccess に負ける 深夜 2 時 2 分に切り替えました。トップページを開くと、**PHP のままでした**。しかも移行済みの前提で古い CSS を消していたので、崩れている。5 分で戻しました。 戻してから、**本番に触らずに原因を調べる**ために、使い捨ての Apache を同じ設定で立てて、書き換えの追跡ログを最大にして 1 URL ずつ叩きました。原因は 3 つ。 1. **ディレクトリへの要求は転送ルールが負ける**。`/about/` のようなディレクトリを要求すると、転送を決めた後に Apache の別の部品(mod_dir)が `index.php` を探しに行って、PHP が勝つ。`DirectoryIndex disabled` を書いて止めた 2. **子ディレクトリの `.htaccess` が親のルールを継承しない**。`RewriteEngine On` を書いた子があると、そこでは親の転送ルールが無かったことになる。5 つの子 `.htaccess` を退避 3. **PHP 側の古いルールが転送より前に書かれていて、そこで打ち切っていた**(`[L]`)。sitemap と llms.txt がこれ どれも `.htaccess` の 1 行の話で、ローカルの検証では出ませんでした。ローカルには子 `.htaccess` を置いていなかったからです。**本番と同じファイル構成で検証していなかった**、と言い換えられます。 2 時 12 分、2 回目。公開 URL 28 本がすべて Next で応答して、成功。 --- ## 切替後: Cloudflare Fonts が React の hydration を壊す (#418) 翌朝、React のページで hydration エラー(サーバーが出した HTML とブラウザが組み立てた HTML が食い違う警告)が全ページで出ていました。ローカルでは 0 件。本番だけ。 原因は **Cloudflare の「Fonts」最適化**でした。HTML の中の Google Fonts の `` を、Cloudflare が配信時に `