Claude CodeでApp Routerへ移行する — Next.jsをルート単位で1ページずつ進める
pagesとappを共存させたまま、ルート1本をworktree1つに切ってClaude Codeに移させる手順。getServerSidePropsの書き換えと確認項目までを扱います。
Pages RouterからApp Routerへの移行は、全ページを一度に書き換えなくても進められます。Next.jsではpagesとappを同じプロジェクトで同時に動かせるので、ルートを1本ずつ移す進め方が取れます。このルート単位の作業は、Claude Codeのworktreeと相性がいい分け方です。1ルート=1worktree=1ブランチにすれば、セッション同士が互いのファイルを書き換えません。手順は、共存の前提、worktreeの切り方、1ページの書き換え、確認の順で進めます。
pagesとappを共存させる前提を押さえる
appディレクトリは、pagesディレクトリと同時に動くように設計されています。ページ単位で段階的に移せるのはこのためです。Next.js 13.4以上が必要で、next@latestに上げたうえでプロジェクトルート(またはsrc/)にappを作ります。
共存させるうえで、先に知っておきたい違いが3つあります。
appのページは既定でServer Componentです。pagesのページはClient Componentとして扱われますpages/_appとpages/_documentは、app/layout.tsxのルートレイアウト1つに置き換わります。ルートレイアウトには<html>と<body>を自分で書きます- 別のルーターが担当するルートへ移るときは、ハードナビゲーションになります。
next/linkの自動プリフェッチも、ルーターをまたいでは効きません
3つ目は移行中の遷移に出ます。pages側のページからapp側のページへ移ると、プリフェッチが効かないハードナビゲーションになります。ルーターをまたぐ遷移を最適化する方法はVercelのガイドにあります。
1ルートを1つのworktreeに割り当てる
Claude Codeは--worktree(-w)に名前を渡すと、.claude/worktrees/<名前>/に作業ディレクトリを作り、worktree-<名前>という新しいブランチで起動します。ルート単位の移行では、この名前をルート名に揃えるのが扱いやすい形です。
# 端末ごとに1ルートずつ起動する
claude --worktree migrate-dashboard
claude --worktree migrate-settingsworktreeは新しいチェックアウトなので、node_modulesは入っていません。起動したらClaudeに依存関係のインストールを頼むか、worktreeのディレクトリで自分で実行します。.envのようなgitignore済みファイルを持ち込みたいときは、プロジェクトルートに.worktreeincludeを置きます。書き方と条件はClaude Code worktreeincludeで扱っています。
.env
.env.localworktreeの起点は、既定ではリモートの既定ブランチ(通常はmain)です。app/layout.tsxのようにすでに自分のブランチで作った土台をworktreeにも持たせたいときは、worktree.baseRefを"head"にします。ブランチ名は指定できず、"fresh"と"head"の2値だけです。
{
"worktree": {
"baseRef": "head"
}
}詳しい挙動はworktree.baseRefとsparsePathsにまとめています。
分担の目安
ルートごとのworktreeに分けられるのは、ほかのルートに触れない作業です。共有されるファイルは1セッションで先に済ませると、あとの衝突が減ります。
worktreeを分ける作業と分けない作業
先に1セッションで
app/layout.tsx、pages/_appの内容を移した全体スタイル、Tailwindのcontent設定。全ルートが読むファイルなので、ここを並行して触ると競合しやすくなります。
worktreeごとに並行で
1ルートのpage.tsx、そのルート専用のコンポーネント、データ取得の書き換え、next/headのmetadata化。ルートごとに独立して進められます。
土台のapp/layout.tsxを先に作る
最初の1回だけ、ルートレイアウトを作ります。pages/_app.tsxとpages/_document.tsxに書いていた内容(フォント、グローバルCSS、共通の<html>属性)をここへ集めます。<head>の中身はnext/headではなく、metadataのエクスポートで管理します。
// app/layout.tsx
export const metadata = {
title: 'Home',
description: 'Welcome to Next.js',
}
export default function RootLayout({
children,
}: {
children: React.ReactNode
}) {
return (
<html lang="ja">
<body>{children}</body>
</html>
)
}Tailwindを使っているなら、tailwind.config.jsのcontentに./app/**/*.{js,ts,jsx,tsx,mdx}を足す作業もここで済ませます。これを忘れると、app側のクラスにスタイルが当たりません。
1ページを移す手順
ページ1枚の移行は、2段階に割ると差分が小さく保てます。
1ルートを動かすまでの流れ
- 1
Client Componentへ移す
pages/index.jsのデフォルトエクスポートを、app/home-page.tsxに移します。先頭に'use client'を書くと、pagesのときと同じ振る舞いのまま動きます。 - 2
page.tsxから呼ぶ
app/page.tsxを作り、1で作ったコンポーネントを読み込みます。page.tsxはServer Componentなので、データ取得はここに書けます。 - 3
古いファイルを消す
動作を確かめたら
pages/側の元ファイルを消し、同じコミットでapp/の新ファイルを入れます。
この2段階を「最も移行しやすい経路」と呼んでいる通り、まずはpagesと近い振る舞いで動かし、Server Componentへの分解は後から進める形です。最初から全部をServer Component化しようとすると、フックや状態管理の修正が一度に押し寄せます。
ルートの対応は次のとおりです。
pages | app | ルート |
|---|---|---|
index.js | apppage.js | ルート/ |
about.js | appabout/page.js | ルート/about |
blog/[slug].js | appblog/[slug]/page.js | ルート/blog/post-1 |
getServerSidePropsをServer Componentへ書き換える
データ取得は、pagesの専用関数から、Server Componentの中のfetch()に変わります。getServerSidePropsにあたるのはcache: 'no-store'です。
// pages/dashboard.js(移行前)
export async function getServerSideProps() {
const res = await fetch(`https://...`)
const projects = await res.json()
return { props: { projects } }
}
export default function Dashboard({ projects }) { /* ... */ }// app/dashboard/page.tsx(移行後)
async function getProjects() {
const res = await fetch(`https://...`, { cache: 'no-store' })
return res.json()
}
export default async function Dashboard() {
const projects = await getProjects()
// ...
}関数名は何でもよく、page.tsxのコンポーネント自体をasyncにします。Claudeに頼む前に、この対応表を手元に置いておくと確認が速くなります。
pagesのAPI | appでの置き換え |
|---|---|
getServerSideProps | appでの置き換えfetch(url, { cache: 'no-store' }) |
getStaticProps | appでの置き換えfetch(url, { cache: 'force-cache' }) |
getStaticPropsのrevalidate: 60 | appでの置き換えfetch(url, { next: { revalidate: 60 } }) |
getStaticPaths | appでの置き換えgenerateStaticParams |
getStaticPathsのfallback | appでの置き換えdynamicParams(既定はtrue) |
pages/api/* | appでの置き換えそのまま動く。app側ではroute.tsも使える |
getStaticPathsが返していた{ params: { id: '1' } }の入れ子は、generateStaticParamsでは[{ id: '1' }]というフラットな配列になります。
Claude Codeに渡す指示と、書き換えの確認
worktreeを起動したら、移行の範囲と禁止事項を最初の指示で固定します。範囲を絞ると、ほかのルートや共有ファイルに手が伸びにくくなります。
pages/dashboard.tsx だけを app/dashboard/page.tsx へ移してください。
- 先に 'use client' のコンポーネントとして移し、動作が同じことを確認してから
getServerSideProps を page.tsx 内の fetch(cache: 'no-store') に書き換える
- app/layout.tsx と tailwind.config.js は変更しない
- next/router は next/navigation に置き換える
- 完了したら next build の結果を報告する繰り返し使う条件は、プロジェクトのCLAUDE.mdに書いておきます。たとえば次のような断片です。
## App Router移行のルール
- 1ルートずつ移す。移すルートは pages/ の元ファイルを同じコミットで消す
- app/ のページは Server Component を既定にし、状態やイベントが要る部分だけ 'use client' に切り出す
- next/router を app/ で import しない(next/navigation を使う)確認項目は機械で拾えるものから
書き換え後の確認は、Claudeに報告させるだけにしないで、コマンドの結果を見られる形にします。
# app/ の中に next/router が残っていないか
grep -rn "next/router" app/
# getServerSideProps などの旧APIが app/ に紛れていないか
grep -rnE "getServerSideProps|getStaticProps|getStaticPaths" app/
# ビルドとテスト
npx next build
npm testapp/配下でnext/routerをインポートすると使えないので、1行目のgrepは移行漏れの検出に向きます。2行目は、旧APIをそのままappに持ち込んでいないかを見ます。テストの回し方はClaude CodeでVitestを回すのようにCLAUDE.mdへコマンドを書いておくと、worktreeごとに同じ手順で走ります。
目視でも確認したい点は次のとおりです。
useRouterの移行。next/navigationのuseRouterはpathnameとqueryを返さないので、usePathname、useSearchParams、useParamsに分けて使いますisReady、asPath、locale、basePathはuseRouterから無くなっています。これらを参照していた箇所は書き直しが必要ですuseSearchParamsを使うコンポーネントは、プリレンダリングの段階ではクライアント側の実行時に描画されます
共通コンポーネントをpagesとappの両方から使う期間は、next/compat/routerのuseRouterが使えます。pages用のフックと同じ形で、ルーターをまたいだ共有のために用意されたものです。appだけで使うようになったらnext/navigationに切り替えます。
サブエージェントに任せるときの注意
ルートごとのセッションを自分で開くかわりに、Claudeにサブエージェントを使わせることもできます。「エージェントにはworktreeを使って」と頼むか、カスタムサブエージェントのfrontmatterにisolation: worktreeを書きます。
---
name: route-migrator
description: pagesの1ルートをapp routerへ移す
isolation: worktree
---
指定された1ルートだけを pages から app へ移し、next build の結果を報告する。
共有ファイルは変更しない。注意点が1つあります。サブエージェントのworktreeが既定の.claude/worktrees/の下にあると、そのworktreeのルートにあるCLAUDE.mdと.claude/rules/は、サブエージェントがファイルを読むときに読み込まれません。起動時に受け取る指示は、メインの会話から引き継がれたものです。worktreeのブランチ側でCLAUDE.mdを書き換えても、サブエージェントには届かない場合があるので、移行ルールはメインのセッションが読むCLAUDE.mdか、サブエージェントの定義本文に書きます。
また、サブエージェントのworktreeは、変更がなければ終了時に自動で削除されます。変更が残ったworktreeは、定期的な掃除の対象になるまでディスクに残ります。
ルートごとの後片付け
マージしたら、worktreeを片付けます。対話セッションを終了すると、変更のないworktreeは自動で削除され、作業が残っているものは残すか消すかを聞かれます。名前を付けたセッションは、変更がなくても削除前に確認されます。
git worktree list
git worktree remove .claude/worktrees/migrate-dashboard.claude/worktrees/はgitignoreに入れておくと、メインのチェックアウトに未追跡ファイルとして現れません。溜まった場合の掃除はagent viewで溜まるworktreeの掃除を参照してください。
落とし穴
同名で開き直すと古い状態に戻ることがある
--worktreeに既存の名前を渡すと、新規作成ではなく既存のworktreeが開きます。変更がなく、自分のブランチ上で、コミットが無いか、PRがマージされてリモートブランチが削除済みのときは、既定のブランチへリセットされます。それ以外は古い先端のまま開くので、前のルートの作業が残っていないかを先に確かめます。
移行が済んだルートの数え方
移行の進み具合は、pages/に残るファイルの数で見えます。全ルートの移行が終わると、pages/_app、pages/_document、pages/404.jsも、それぞれlayout.tsx、not-found.tsxに置き換えて消せます。pages/_error.jsは、粒度の細かいerror.jsに置き換わります。
ルート単位で進めるときの要点は、共有ファイルを先に固め、ルート固有の作業だけを並行させることです。Next.js側の開発支援には、実行中の開発サーバーからビルドエラーを直接取れるNext.js MCPサーバーもあります。新規にNext.jsアプリを立てる場合はNext.js SaaS MVPの始め方が別の入口です。
参照した一次ソース
この記事の記述は、次の一次ソースと照合しています。更新時に参照先が変わった場合は一覧も更新します。