はじめに
個人開発している「近況ノート」を、Vite + React RouterのSPAからNext.js App Routerへ移行しました。
前編では、SDD(Spec-Driven Development/仕様駆動開発)の練習として、次の文書を作ったところまで紹介しました。
steering documents
product.md
tech.md
structure.md
Next.js移行spec
requirements.md
design.md
tasks.md
tasks.mdでは、Next.js移行を21個のタスクに分解しました。
中編では、そのうちタスク1〜18を使い、Vite版を残したままNext.js版を実装し、本番へ出せる状態にするまでをまとめます。
本番環境を変更するタスク19〜21は、後編で扱います。
21個のタスクを一度に実行しない
今回の実装では、AIへtasks.mdを渡して「全部実装して」と依頼するのではなく、原則として上から1件ずつ進めました。
タスクは、次のような単位に分かれています。
| タスク | 内容 |
|---|---|
| 1〜3 | Next.jsとOpenNextの基盤を追加する |
| 4〜6 | Hono APIとの接続境界を作る |
| 7〜14 | 共通UIと各画面をApp Routerへ移行する |
| 15〜16 | Next.jsを既定の実行系にして開発環境を整える |
| 17〜18 | 受け入れ検証と本番リリース手順を作る |
| 19〜21 | 本番切り替えと旧Vite版の削除 |
各タスクには、単なる作業内容だけでなく、次の情報を持たせました。
- 変更対象のファイル
- タスクの目的
- 再利用する既存コード
- 対応する要件
- 実装時の制約
- 完了条件
タスクを開始したらチェック状態を変更し、実装と検証が完了したらImplementation Logsへ結果を残します。
.spec-workflow/specs/nextjs-migration/
tasks.md
Implementation Logs/
task-1_....md
task-2_....md
...
実装ログには、変更したファイル、作成したファイル、実行したテスト、設計との差異などが記録されました。
これにより、途中で問題が起きても、どのタスクで何を変更したのかを追いやすくなりました。
Vite版とNext.js版を並行させる
最初のタスクでは、Next.js、OpenNext、Vitest、Testing Libraryなど、移行に必要な依存関係を追加しました。
この時点では、既存のVite版を削除していません。
まずVite版を動かしたまま、Next.js版を別のコマンドで起動・ビルドできる状態にしました。
{
"scripts": {
"dev:next": "next dev",
"build:next": "tsc --project tsconfig.next.json --noEmit && next build",
"build:cf": "opennextjs-cloudflare build",
"preview:cf": "opennextjs-cloudflare preview --port 8790",
"test": "vitest run --passWithNoTests"
}
}
移行初期に必要だったのは、完成形へ一気に置き換えることではなく、次の状態を作ることでした。
既存のVite版が動く
Next.js版も起動できる
OpenNextでCloudflare向けにビルドできる
両方を同じ期間に検証できる
タスク3では、App Routerの最小構成を作りました。
ルートレイアウト、共通CSS、メタデータ、loading.tsx、error.tsx、not-found.tsxなどを用意し、まだ主要画面を移行していない状態でOpenNextのビルドまで確認しました。
この順番にしたことで、後から問題が発生したときに「Next.jsの基盤で失敗したのか」「移行した画面で失敗したのか」を分けて考えられました。
Next.jsとHonoを2つのWorkerに分ける
今回の移行で最も大きな構成変更は、Cloudflare Workerを2つに分けたことです。
ブラウザ
-> Next.js / OpenNext 公開Worker
-> Service Binding
-> Hono API Worker
-> Cloudflare D1
移行前は、Hono WorkerがAPIとViteの静的ファイルを同じWorkerから配信していました。
移行後は、OpenNextが生成するWorkerを公開側に置き、既存のHono APIを内部Workerとして残しました。
公開Workerの設定には、Hono API WorkerへのService Bindingを追加しています。
{
"name": "kinkyo-note",
"main": ".open-next/worker.js",
"assets": {
"directory": ".open-next/assets",
"binding": "ASSETS"
},
"services": [
{
"binding": "API",
"service": "kinkyo-note-api"
}
]
}
一方、D1とLINE LoginのSecretはAPI Worker側だけに持たせます。
{
"name": "kinkyo-note-api",
"main": "worker/index.ts",
"d1_databases": [
{
"binding": "DB",
"database_name": "kinkyo-note-db",
"migrations_dir": "drizzle"
}
]
}
公開WorkerにはD1をBindingしません。
Next.jsのServer ComponentからD1へ直接アクセスする構成も考えられますが、今回は採用しませんでした。
既存のHono APIには、すでに次の処理が集まっていたためです。
- セッションの確認
- グループと投稿の認可
- CSRFとOriginの検証
- LINE OAuth
- Zodによる入力検証
- Drizzle ORMによるD1アクセス
Next.js移行と同時にこれらをRoute HandlerやServer Actionへ移すと、変更範囲が大きくなります。
そこで、Hono APIをデータ・認証・認可の安定した境界として残しました。
/api/*をService Bindingへ転送する
ブラウザから見えるAPI URLは、移行前と同じ/api/*を維持しました。
Next.js側にはcatch-allのRoute Handlerを置き、受け取ったリクエストをService Bindingへ転送します。
import { getCloudflareContext } from '@opennextjs/cloudflare'
import { forwardApiRequest } from '@/infrastructure/api/apiGateway'
export const dynamic = 'force-dynamic'
async function handleApiRequest(request: Request): Promise<Response> {
const { env } = await getCloudflareContext({ async: true })
return forwardApiRequest(request, env.API)
}
export {
handleApiRequest as DELETE,
handleApiRequest as GET,
handleApiRequest as OPTIONS,
handleApiRequest as PATCH,
handleApiRequest as POST,
handleApiRequest as PUT,
}
Gatewayでは、メソッド、ヘッダー、Cookie、Bodyを保ったままAPI Workerへ渡します。
export function forwardApiRequest(
request: Request,
api: ApiService,
): Promise<Response> {
const forwarded = request.clone()
const hasBody = forwarded.method !== 'GET' && forwarded.method !== 'HEAD'
return api.fetch(forwarded.url, {
method: forwarded.method,
headers: forwarded.headers,
body: hasBody ? forwarded.body : undefined,
redirect: 'manual',
signal: forwarded.signal,
})
}
redirect: 'manual'も重要でした。
LINE OAuthの開始APIは、LINEの認可画面へ302を返します。Gatewayがリダイレクトを自動追跡すると、ブラウザへ返すべき302と複数のSet-Cookieを正しく中継できません。
この問題は、ログイン画面を実装したタスク14の実動確認で見つかりました。
Server Component専用のAPIクライアントを作る
Server Componentからも、Hono API WorkerをService Binding経由で呼び出します。
サーバー用APIクライアントでは、受信したCookieとOriginを引き継ぎ、APIのステータスをNext.js側で扱えるエラーへ変換しました。
export async function requestServerApi<T>(
path: string,
init: ServerApiRequestInit = {},
context: ServerApiRequestContext,
): Promise<T> {
const requestHeaders = new Headers(init.headers)
if (context.cookie && !requestHeaders.has('Cookie')) {
requestHeaders.set('Cookie', context.cookie)
}
const request = new Request(buildApiUrl(path, context.origin), {
...init,
headers: requestHeaders,
})
const response = await context.api.fetch(request)
if (!response.ok) {
throw new ServerApiError(response.status, 'API request failed')
}
return response.json() as Promise<T>
}
データ取得関数は、Reactのcache()で包みました。
export const getGroup = cache(async (slug: string): Promise<Group> => {
const result = await serverApiRequest<{ group: Group }>(
`/api/groups/${encodeURIComponent(slug)}`,
)
return result.group
})
これにより、同じレンダリングの中でlayoutとpageが同じグループを取得しても、API呼び出しを重複させずに済みます。
一方、利用者固有情報を含むため、永続的なNext.js Cacheは初回移行では使わない方針にしました。
共通layoutでグループの存在を確認する
グループ配下のURLは、App Routerの動的セグメントへ移行しました。
src/app/g/[slug]/
layout.tsx
page.tsx
loading.tsx
error.tsx
not-found.tsx
feed/page.tsx
new/page.tsx
post-login-submit/page.tsx
classmates/[id]/edit/page.tsx
layout.tsxではグループを取得し、APIが404を返した場合だけnotFound()へ接続します。
export default async function GroupLayout({ children, params }) {
const { slug } = await params
try {
await getGroup(slug)
} catch (error) {
if (isServerApiError(error) && error.kind === 'not-found') {
notFound()
}
throw error
}
return children
}
API障害までNot Foundとして隠さず、予期しないエラーはerror.tsxへ渡します。
また、以前使っていた固定URL用のリダイレクトは追加しませんでした。存在しないslugは、そのままNot Foundとして扱います。
グループホームをServer Component中心にする
移行前のグループホームは、画面を表示した後にuseEffectでAPIを呼び出していました。
移行後は、Server Containerで初期データを並行取得します。
export async function GroupHomeContainer({ slug }: { slug: string }) {
const [group, classmates, myClassmates] = await Promise.all([
getGroup(slug),
getClassmates(slug),
getOptionalMyClassmates(slug),
])
return (
<GroupHomeView
classmates={classmates}
group={group}
myClassmateId={myClassmates[0]?.id}
slug={slug}
/>
)
}
グループ情報、公開中の近況、自分の投稿情報は独立しているため、Promise.allで開始します。
未ログインの場合、自分の投稿を取得するAPIだけは401になります。これはページ全体のエラーではないため、空配列へ変換して未投稿状態として表示します。
初期表示に必要な内容はServer Componentで描画し、Clipboard APIとWeb Share APIを使う共有ボタンだけを小さなClient Componentにしました。
フィードは初期表示と操作を分ける
フィードも、投稿一覧の初期取得をServer Componentへ移しました。
一方、次の操作にはブラウザ側の状態が必要です。
- 新着順と名前順の切り替え
- いいね処理中の投稿ID
- いいね後の件数更新
- コメントの取得と投稿
そこで、初期データはServer Containerで取得し、操作に必要な最小限の状態だけをFeedClientへ渡しました。
FeedContainer(Server)
-> FeedView
-> FeedClient(Client)
-> ClassmateCard
-> CommentsPanel
コメントは、フィードの初期表示時に投稿ごとに取得するとN+1になります。
今回は、コメントパネルを開いたときだけ取得する既存の挙動を維持しました。
移行後は、初期HTMLに投稿一覧が含まれつつ、いいねやコメントはこれまでどおり操作できます。
フォームはServer判定とClient操作に分ける
投稿関連の画面は、すべてServer Componentにできるわけではありません。
近況ノートのフォームは、次のブラウザ機能を使っています。
- React Hook Form
-
localStorageへの下書き保存 - 画像のプレビューと変換
- CSRFトークン付きの送信
- LINE Login後の下書き復元
そこで、画面を次のように分けました。
| 画面 | Server側 | Client側 |
|---|---|---|
| 新規投稿 | グループと登録状態の確認 | 入力、下書き、画像、送信 |
| ログイン後投稿 | グループの存在確認 | 下書きの復元と再送信 |
| 編集 | ID、認証、所有権の確認 | 入力、画像、更新 |
| ログイン | セッションとreturnToの確認 |
LINE Loginリンクの操作 |
新規投稿画面では、すでに投稿済みなら編集画面へリダイレクトします。
未ログインはエラー画面にせず、そのままフォームへ入力できます。送信時にLINE Loginへ移動しても、下書きを残してログイン後に投稿を再開する既存仕様を維持しました。
ログイン後の再送信画面は、ブラウザStorageが必須です。そのため、無理にServer Componentへ寄せず、専用のPostLoginSubmitClientへ隔離しました。
編集画面では、Server側で投稿IDと編集権限を確認します。
try {
classmate = await getEditableClassmate(slug, classmateId)
} catch (error) {
if (isServerApiError(error)) {
if (error.kind === 'unauthenticated') {
redirect(loginPath(editClassmatePath(slug, classmateId)))
}
if (error.kind === 'not-found' || error.kind === 'forbidden') {
notFound()
}
}
throw error
}
権限がないことを画面上のボタン非表示だけで判断せず、Hono API側の認可も残しています。
実装中に設計どおり進まなかったこと
要件と設計を先に作っても、実装がそのまま進むわけではありませんでした。
今回の移行でも、実際に動かして初めて分かったことがありました。
TypeScript 7とtypescript-eslint
移行開始時にTypeScriptを安定最新版へ更新したところ、使用していたtypescript-eslintが実際にクラッシュしました。
対応版を待つ、TypeScriptを戻す、別のLintへ変更するという選択肢を検討し、今回はBiomeへ置き換えました。
{
"scripts": {
"lint": "biome lint . --max-diagnostics=50"
}
}
Next.js移行そのものとは別の変更ですが、型検査とLintを通すというタスクの完了条件を守るために必要な判断でした。
.dev.varsからのSecret露出
ローカル環境を確認したところ、ルートに置いた.dev.varsが公開Next.js Workerにも読み込まれる可能性があることが分かりました。
そこで、API Worker専用の.dev.vars.apiへ分離しました。
公開Worker
D1 Bindingなし
LINE Secretなし
API Worker
D1 Bindingあり
.dev.vars.apiを読み込む
設定ファイル上で2 Workerに分けただけでは、Secretの境界として十分ではありませんでした。
OpenNextとproxy.ts
Next.js側では、リクエストごとにnonceを生成したCSPを設定しました。
当時のOpenNextではNode.jsのproxy.tsをバンドルできなかったため、CSPについてはEdgeのmiddleware.tsを継続しました。
export function middleware(request: NextRequest): NextResponse {
const nonce = createNonce()
const contentSecurityPolicy = buildContentSecurityPolicy({
isDevelopment: process.env.NODE_ENV === 'development',
nonce,
})
const requestHeaders = new Headers(request.headers)
requestHeaders.set('x-nonce', nonce)
const response = NextResponse.next({
request: { headers: requestHeaders },
})
setSecurityHeaders(response.headers, contentSecurityPolicy)
return response
}
公式の推奨だけで決めず、Cloudflare上で実際に使うアダプターの対応状況も確認する必要がありました。
OAuthリダイレクトの自動追跡
単体テストではAPI Gatewayの302レスポンスを確認していましたが、2 Workerを実際に起動すると、OAuthのリダイレクトが期待どおりブラウザへ返らない問題が見つかりました。
Service Bindingへ渡すfetchがリダイレクトを自動追跡していたことが原因でした。
そこで、前述したredirect: 'manual'を追加し、302、Location、4個の一時Cookieがそのままブラウザへ返ることを確認しました。
SDDによって予定外の問題がなくなるわけではありません。
ただし、「CookieとOAuthの振る舞いを維持する」という要件と、「実Workerで確認する」という完了条件があったため、実装完了前に問題を見つけられました。
Next.jsを既定の実行系にする
主要画面の移行が終わったタスク15で、日常的に使うdevとbuildをNext.jsへ切り替えました。
この時点でも、Vite版のソースと依存関係は削除していません。
旧構成はlegacy/viteへ隔離し、問題があれば戻せる状態を維持しました。
通常の開発・ビルド
-> Next.js / OpenNext
ロールバック用
-> Vite / React Router
タスク16では、公開WorkerとAPI Workerをローカルで同時に起動するスクリプトを追加しました。
npm run dev:workers
このコマンドでは、OpenNextのビルド、ローカルD1マイグレーション、API Worker、公開Workerの起動、Service Bindingの疎通確認までを行います。
Docker Composeでも同じ2 Worker構成を再現できるようにし、Secretは読み取り専用ファイルとして渡しました。
OpenNextの実プロセスで受け入れ検証する
タスク17では、移行全体を確認するacceptanceスクリプトを作りました。
const checks = [
['run', 'lint'],
['run', 'typecheck'],
['test'],
['run', 'build:cf'],
['run', 'cf:typegen:check'],
['run', 'check:public-bundle'],
]
これらを実行したあと、公開WorkerとAPI Workerを実際に起動し、HTTPで主要な振る舞いを確認します。
確認対象には、次の内容を含めました。
- 正規URLの初期HTML
- 存在しないslugと旧固定URLのNot Found
- グループホーム、フィード、新規投稿、編集、ログイン
-
/api/healthと読み取りAPI - 未認証の更新API
- OAuthの302と4個のCookie
- CSP nonceとセキュリティヘッダー
- 公開バンドルへのSecret混入
- Worker Bindingの型定義のずれ
npm run acceptance
この時点では、Lint、TypeScript 7の型検査、83件の単体・統合テスト、OpenNextビルド、2 WorkerのHTTP受け入れ検証が通る状態になりました。
ブラウザE2EとPlaywrightも候補にありましたが、今回は利用者判断で対象外にしました。
代わりに、ブラウザを完全に再現することよりも、OpenNextとWranglerの実プロセス上でルーティング、初期HTML、Cookie、OAuth、Service Bindingを確認することを優先しました。
本番へ出す前にロールバック手順を作る
タスク18では、まだ本番へデプロイせず、先にデプロイとロールバックのランブックを作りました。
本番切り替えは、次の2段階で行う設計です。
1. Hono API Workerを先にデプロイ
-> 旧Vite公開Workerから互換性を確認
2. Next.js公開Workerへ切り替え
-> 主要画面、API、LINE Loginを確認
ランブックには、次の内容を記載しました。
- 必要な変数、Secret、Binding
- デプロイ前に記録するversion ID
- API Workerのスモークテスト
- 公開Worker切り替え後の確認項目
- 30分と24時間の監視項目
- ロールバックを判断する条件
- 即時ロールバックのコマンド
さらに、API Workerと公開Workerのdry-run、リモートService Binding用のスモークテスト、ロールバック用スクリプトを用意しました。
ここまで終わって、初めて本番デプロイへ進める状態になりました。
中編のまとめ
中編では、SDDで作成したタスク1〜18に沿って、Next.js移行版を実装しました。
主な流れは次のとおりです。
Vite版を残してNext.jsを並行導入
-> Next.jsとHono APIを2 Workerへ分離
-> Service BindingのAPI Gatewayを作成
-> 各画面をServer/Client Componentへ分割
-> Next.jsを既定の実行系へ変更
-> OpenNext実環境で受け入れ検証
-> デプロイとロールバックのランブックを作成
要件と設計を先に作っても、TypeScript、Secret、OpenNext、OAuthなど、実装中に予定外の問題は発生しました。
それでも、各タスクに制約と完了条件があったことで、「とりあえず画面が動いた」状態で終わらず、既存の認証、API、Cookie、セキュリティを確認しながら進められました。
また、実装ログが残るため、設計から変わった箇所と、その理由も後から追えます。
後編では、Hono API Workerの先行デプロイ、Next.js公開Workerへの切り替え、監視、そして旧Vite/React Router資産を削除して移行を完了するまでをまとめます。