はじめに
個人開発している「近況ノート」を、Vite + React RouterのSPAからNext.js App Routerへ移行しました。
前編では、steering documents、要件、設計、21個の実装タスクを作るまでをまとめました。
中編では、タスク1〜18に沿ってNext.js版を実装し、OpenNextの実プロセスを使った受け入れ検証と、本番デプロイのランブックを作りました。
後編では、残りのタスク19〜21を扱います。
タスク19
Hono API Workerを先行デプロイする
タスク20
Next.js公開Workerへ切り替える
タスク21
旧Vite/React Router資産を削除する
最後に、SDD(Spec-Driven Development/仕様駆動開発)を一巡して分かったことも振り返ります。
本番切り替えを2段階に分ける
移行前と移行後の構成は、次のようになっています。
移行前
ブラウザ
-> Viteの静的ファイルを配信するHono Worker
-> Hono API
-> Cloudflare D1
移行後
ブラウザ
-> Next.js / OpenNext 公開Worker
-> Service Binding
-> Hono API Worker
-> Cloudflare D1
公開画面とAPIを同時に切り替えると、不具合が起きたときに原因を切り分けにくくなります。
そこで、本番移行は次の順番にしました。
1. Hono API Workerだけを新しい構成へ更新
2. 旧Vite公開WorkerからAPI互換性を確認
3. Next.js公開Workerへ切り替え
4. 画面、API、LINE Loginを確認
5. 安定確認後に旧Vite版を削除
新しいHono API Workerは、旧Vite版とNext.js版の両方から利用できるように設計しています。
API側に問題があれば、公開Workerを切り替えずに終了できます。Next.js公開Workerの切り替え後に問題が見つかった場合も、Cloudflareに残した旧Viteバージョンへ戻せます。
本番操作の前に明示的な許可を入れる
タスク19と20には、本番デプロイの直前に利用者から明示的な許可を得る条件を入れていました。
タスクを開始した
!= 本番へデプロイしてよい
実装と事前検証が完了した
-> 対象と影響を説明する
-> 利用者が明示的に許可する
-> 本番操作を実行する
タスクには実装だけでなく、外部環境を変更してよい境界も書きました。
本番操作の前には、次の内容を確認・記録しています。
- ローカルの受け入れ検証が成功している
- Cloudflareへのdry-runが成功している
- 現在のWorker version ID
- ロールバック先のversion ID
- D1のマイグレーションが残っていない
- D1 Time Travelのブックマーク
- 必要なBindingとSecretの名前と有無
Secretの値は、ログやリリース記録へ残しません。
タスク19:Hono API Workerを先行デプロイする
最初に、Hono API Workerだけを本番へデプロイしました。
この時点では、利用者がアクセスする公開Workerは旧Vite版のままです。
ブラウザ
-> 旧Vite公開Worker
-> 新Hono API Worker
-> D1
これにより、画面を変えずにAPIだけを検証できます。
まず読み取りだけを確認する
デプロイ直後は、最初から本番D1へ検証データを書き込まず、読み取りと拒否系から確認しました。
主な確認結果は次のとおりです。
| 確認項目 | 期待する結果 |
|---|---|
/api/health |
200 |
| 公開グループの取得 | 200 |
匿名状態の/api/me
|
200 |
| 未認証のPOST/PATCH | 401 |
| 未認証のCSRF取得 | 401 |
| OAuth開始 | LINEへ302 |
| 不正なOAuth callback | 400 |
| 存在しない画像/API | 404 |
接続は、ローカルで起動したOpenNext Workerから、本番Hono API Workerへのremote Service Bindingを使いました。
ローカルOpenNext Worker
-> remote Service Binding
-> 本番Hono API Worker
-> 本番D1
公開Workerを変更する前に、Next.js側のAPI Gatewayと本番API Workerの組み合わせを確認できます。
実環境でrequest streamの問題が見つかる
読み取り専用のスモークテストでは、Bodyを持つ未認証リクエストでrequest streamの例外が見つかりました。
認証ミドルウェアが401を返した場合、後続の処理はリクエストBodyを読みません。remote Service Bindingを経由する実環境では、未読のstreamが残った状態でレスポンスを終了するケースが問題になりました。
そこで、Honoの共通エラー処理とNot Found処理で、未読Bodyを上限付きでdrainするように修正しました。
修正後に、Bodyありの未認証POSTとPATCHを再実行し、401が正常に返り、stream例外が発生しないことを確認しました。
ローカルの単体テストだけでは見つからず、remote Service Bindingを使ったスモークテストで見つかった問題です。
要件に「Cloudflareの実行環境で確認する」、タスクに「API Workerを先行検証する」という条件を入れていたことで、公開画面を切り替える前に修正できました。
専用fixtureで認証済み操作を確認する
読み取りと拒否系が成功したあと、明示的な許可を得て、タスク19専用の検証データを本番D1へ作りました。
既存レコードを使うと、検証中の更新や削除が実データへ影響する可能性があります。
そこで、一意なslugとユーザーを持つfixtureを作り、検証後に関連データをすべて削除する手順にしました。
確認した内容は次のとおりです。
- 認証済みの
/api/me - CSRFトークンの取得
- CSRFなしの更新が403になる
- 不正Originからの更新が403になる
- CSRF付きの近況作成
- 自分の近況の読み取りと更新
- 他の検証ユーザーからの更新拒否
- アバターのアップロード、取得、削除
- 非公開画像の
private, no-store - 公開画像のキャッシュヘッダー
所有権の検証では、他の検証ユーザーが近況やアバターを更新しようとしても404を返し、対象データが変化していないことまで確認しました。
検証後は、次の9分類について対象件数が0になったことを確認しています。
users
sessions
identities
groups
classmates
avatars
likes
comments
comment likes
D1スキーマの変更は行わず、既存レコードも更新していません。
実LINE Loginはタスク20へ残す
タスク19では、OAuth開始APIが次の内容を返すところまで確認しました。
- 302
- LINEの認可画面を指す
Location -
HttpOnly、SameSite=Lax、Path=/を持つ4個の一時Cookie - 不正なcallbackの拒否
ただし、実際のLINE認可、callback、トークン交換、セッション作成はまだ確認できません。
callback先の公開Originは旧Vite Workerのままだからです。
当初のタスク19だけでOAuth確認を完結させず、実LINE Loginは公開Next.js Workerへ切り替えたタスク20の必須条件へ移しました。
これも、実装中に分かった環境上の制約を設計とタスクへ反映した例です。
タスク20:Next.js公開Workerへ切り替える
Hono API Workerの本番検証と、もう一度すべての受け入れ検証を行ったあと、Next.js公開Workerへ切り替えました。
切り替え前には、旧Vite版のversion IDをロールバック先として記録しました。
旧Vite公開Worker 100%
-> OpenNextをデプロイ
-> Next.js公開Worker 100%
-> スモークテスト
-> 問題があれば旧Vite versionへロールバック
タスク20の段階では、旧Viteのソースも依存パッケージも削除していません。
画面とルーティングを確認する
公開切り替え後に、主要画面のHTTPレスポンスと初期HTMLを確認しました。
- グループホーム
- フィード
- 新規投稿
- ログイン後投稿
- 編集画面
- ログイン画面
- Not Found
旧固定URLは、既定のグループへリダイレクトされず、設計どおりNot Foundになることも確認しました。
単にHTTP 200を見るだけでなく、初期HTMLに画面の主要な内容が含まれていることを確認しています。
これにより、Server Componentへ移した初期データ取得が本番のOpenNext環境でも機能していると判断しました。
CSPと公開バンドルを確認する
本番レスポンスでは、リクエストごとに生成したnonceを含むCSPを確認しました。
script-src
'self'
'nonce-...'
'strict-dynamic'
本番環境ではunsafe-inlineとunsafe-evalを含めず、Next.jsが出力したインラインスクリプト13本のnonceがレスポンスヘッダーと一致することを確認しました。
そのほか、次の項目も確認しています。
X-Content-Type-OptionsReferrer-PolicyPermissions-Policy- 公開バンドルへのLINE Secretや管理用tokenの混入がない
- 公開WorkerにD1 Bindingがない
公開Gateway経由でAPIを再確認する
タスク19ではローカルのOpenNext Workerを呼び出し元にしました。
タスク20では、本番の公開OriginからNext.js API Gatewayを通して、同じAPIを再確認しました。
本番の公開Origin
-> Next.js API Gateway
-> Service Binding
-> Hono API Worker
-> D1
認証済み読み取り、CSRF、近況の作成と更新、所有権、アバターのアップロードと削除まで確認し、専用fixtureは再び完全に削除しました。
実際のLINE Loginを確認する
次に、実際のLINE Loginを操作しました。
確認した流れは次のとおりです。
ログイン画面
-> LINEの認可画面
-> Next.js公開Originのcallback
-> Hono API Workerでトークン交換
-> セッション作成
-> セッションCookieを設定
-> 安全なreturnToへ復帰
実LINE Login後に近況ノートへ戻り、セッションが有効になっていることを確認しました。
LINE Loginの確認後も、公開WorkerとAPI Workerのエラーログは0件でした。
自動化しにくい外部認証については、HTTPの自動テストだけで完了にせず、最後に実際の認可フローを通しています。
30分間監視してロールバックを判断する
スモークテストの成功後、公開WorkerとAPI Workerを監視しました。
確認対象は次のとおりです。
/api/health
/login
/g/hanchu
/api/groups/hanchu
公開Workerのエラーログ
API Workerのエラーログ
1分間隔で25回確認し、結果は次のとおりでした。
- HTTP監視の失敗:0件
- 連続ヘルスチェック失敗:0件
- 公開Workerのエラー:0件
- API Workerのエラー:0件
- 最大応答時間:482ms
事前に決めたロールバック条件へ該当しなかったため、Next.js公開Workerを維持しました。
旧Vite版のversionは、この時点でもロールバック先としてCloudflare上に残しています。
タスク21:旧Vite/React Router資産を削除する
タスク21は、移行が成功したからすぐ実行するのではなく、安定観測後に開始する条件でした。
当初のランブックでは、30分の監視に加えて24時間の観測も予定していました。
今回は、次の結果を確認したうえで、利用者の明示判断によって24時間観測を省略しました。
- 本番の主要画面とAPIが正常
- 実LINE Loginが成功
- 30分監視で失敗0件
- 公開/API Workerのエラーログ0件
- ロールバック条件に該当しない
これは、仕様を無視して先へ進んだのではなく、変更した判断と根拠をリリース記録へ残したうえで進めています。
SDDの文書は、一度作ったら絶対に変更しない契約ではありません。
状況に合わせて変更できますが、「誰が、何を根拠に変えたか」を残すことが重要だと感じました。
削除したもの
旧Vite版は、移行中のロールバック用としてlegacy/viteへ隔離していました。
タスク21では、次の資産を削除しました。
-
legacy/vite配下の旧SPA - ViteとReact Routerの直接依存
- Vite用の実行・ビルドスクリプト
- Vite用のTypeScript設定
- 旧SPA専用のhook
- 到達不能になったuse case
- 未使用になったAPIクライアント関数
- 重複したUIとCSS
直接依存からは、次のパッケージを削除しました。
vite
react-router-dom
@vitejs/plugin-react
@tailwindcss/vite
タスク21全体では、約1,800行を削除しました。
ロールバック手段はローカルの旧ソースではなく、Cloudflareに保持されている直前のデプロイバージョンへ切り替わります。
clean installから最終確認する
旧資産を削除したあと、現在のnode_modulesが偶然動いているだけではないことを確認するため、ロックファイルから依存関係を再構築しました。
npm ci
npm run acceptance
最終確認の結果は次のとおりです。
- clean install成功
- Lintエラー0件
- TypeScript 7の型検査成功
- 21ファイル、83テスト成功
- OpenNextビルド成功
- Worker Bindingの型検査成功
- 公開バンドルのSecret検査成功
- 2 WorkerとローカルD1の起動成功
- Service BindingのHTTP受け入れ検証成功
タスク21では、本番Workerの再デプロイやD1スキーマの変更は行っていません。
これで、Vite/React RouterとNext.jsが併存する移行期間を終え、Next.js/OpenNextの単一構成になりました。
最終的な構成
移行完了後の構成は次のとおりです。
Next.js App Router
-> Server Componentsで初期データ取得
-> Client Componentsでフォームと対話操作
-> 同一オリジンの/api/*
-> Service Binding
-> Hono API Worker
-> Drizzle ORM
-> Cloudflare D1
結果をまとめると、次のようになりました。
| 項目 | 結果 |
|---|---|
| 移行元 | Vite + React Router |
| 移行先 | Next.js App Router + OpenNext |
| 実行環境 | Cloudflare Workers |
| Worker構成 | 公開Next.js Worker + 内部Hono API Worker |
| 実装タスク | 21件完了 |
| 単体・統合テスト | 83件成功 |
| D1スキーマ変更 | なし |
| 30分監視 | 25回中失敗0 |
| 旧Vite資産 | 削除済み |
SDDを試してよかったこと
今回、SDDを一巡して特によかったのは、AIへ渡す指示が実装タスクだけで終わらなかったことです。
何を壊してはいけないかを先に決められた
今回の移行では、Next.jsを導入すること以上に、次の内容を維持することが重要でした。
- 既存URL
- Hono APIの契約
- D1のデータ
- LINE Login
- CookieとCSRF
- 投稿途中の下書き
- 編集権限
- モバイル向けの操作
これらを要件と受入条件にしたことで、画面が表示されたことだけを移行完了にせずに済みました。
大きな移行を停止可能な単位に分けられた
21個のタスクは多く見えますが、それぞれに完了条件と制約があります。
たとえば、Next.jsの基盤を作るタスクでは画面を移行しない、画面移行中はVite版を削除しない、本番APIの検証が失敗したら公開Workerを切り替えない、といった境界です。
各段階で止まれるため、途中の問題を次のタスクへ持ち込みにくくなりました。
本番運用も仕様に含められた
実装タスクだけでなく、次の内容も要件、設計、タスクへ含めました。
- dry-run
- 本番操作前の明示許可
- version IDの記録
- D1 Time Travel
- 専用fixtureの作成と完全削除
- スモークテスト
- 監視
- ロールバック条件
移行では、コードが完成しても安全に切り替えられなければ完了とはいえません。
リリース作業まで同じspecに含めたことは、今回のSDDで特に効果が大きかった部分です。
予定外の判断を記録できた
実装中には、設計時点では分からなかった問題が複数ありました。
- TypeScript 7とLintツールの互換性
-
.dev.varsから公開WorkerへのSecret露出 - OpenNextとNext.jsの
proxy.ts対応差 - OAuthリダイレクトの自動追跡
- remote Service Bindingのrequest stream
- 実LINE Loginを確認できるタイミング
これらは、実装ログ、設計書、タスク、リリース記録へ反映しました。
最初の設計と違うこと自体ではなく、理由が分からないままコードだけ変わることを防げたのがよかった点です。
SDDで大変だったこと
一方で、すべての開発に同じ粒度のSDDが必要だとは感じませんでした。
文書の量が多くなる
今回作成したのは、3つのsteering documents、要件定義書、設計書、21個のタスク、各タスクの実装ログ、デプロイランブック、リリース記録です。
AIが文章を生成しても、内容を確認して承認するのは人間です。
文書同士に矛盾がないか、実装可能な内容か、意図しない制約が入っていないかを確認する必要があります。
仕様を作ること自体が目的になると、実装より文書の管理が重くなる可能性があります。
仕様を書いても実環境の検証は必要
要件と設計が詳しくても、Cloudflareの実環境で発生したrequest stream問題までは事前に分かりませんでした。
SDDは、不具合を自動的になくす方法ではありません。
何をテストし、どの状態なら完了とするかを先に決める方法として捉える方が近いと感じました。
仕様は更新し続ける必要がある
実装中には、利用しているライブラリやアダプターの制約に合わせて設計を変更します。
今回は、実LINE Loginの確認をタスク19から20へ移し、24時間観測は明示判断によって省略しました。
仕様を固定して守るだけではなく、実際の判断と一致するように更新し続ける必要があります。
最終判断は人間に残る
AIは、コードの実装、テスト、ログ確認、リリース記録の作成まで進められます。
ただし、本番へデプロイするか、検証データを書き込むか、観測期間を短縮するかといった判断は、影響を理解した人間が行う必要があります。
今回は、その境界をタスク内へ明示しました。
SDDが向いていそうな作業
今回の経験では、次のような作業はSDDと相性がよさそうです。
- フレームワークやインフラの移行
- 認証、認可、データを含む変更
- 既存機能を維持した大規模リファクタリング
- 複数段階のデプロイが必要な作業
- ロールバック条件を先に決めたい作業
- AIへ長い実装を任せたい場合
一方、次のような変更へ同じ量の文書を作ると重くなりそうです。
- 文言だけの修正
- 小さなUI調整
- 影響範囲が限定されたバグ修正
- 実装方法と完了条件が明らかな変更
すべてを同じ形式にするのではなく、変更のリスクと不確実性に合わせて、要件、設計、タスクの粒度を変える必要があります。
まとめ
近況ノートのNext.js移行では、最初にプロジェクトの前提、移行要件、設計、21個の実装タスクを作りました。
その後、Vite版を残したままNext.js版を実装し、Hono API Workerの先行デプロイ、Next.js公開Workerへの切り替え、監視、旧Vite資産の削除まで進めました。
SDDを使えば、AIが自動的に安全な実装を作ってくれるわけではありません。
それでも、次の内容を実装前に文章にできたことは、大きな移行をAIと進めるうえで役立ちました。
何を変えるか
何を変えないか
どの順番で進めるか
どこで止まるか
何を確認すれば完了か
問題が起きたらどう戻すか
今回の経験から、SDDは「仕様どおりコードを生成する方法」というより、AIと人間が同じ判断基準を持ちながら、変更を小さく進めるための方法だと感じました。