Claude Codeで画面遷移図と画面一覧を起こす — Next.jsのルートからMermaidへ
Next.jsのapp配下を走査し、画面一覧の表と画面遷移図(Mermaid)を自動で出す手順です。認証が要る画面への印の付け方と、拾えない遷移の見分け方も扱います。
Next.jsのApp Routerなら、画面一覧はフォルダ構成から、画面遷移は<Link>とrouter.pushの呼び出しから機械的に起こせます。手で描いた遷移図は改修のたびに古くなりますが、走査スクリプトをリポジトリに置けば、いつでも同じ手順で作り直せます。
この記事では、app/配下を読んで画面一覧の表とMermaidの遷移図を出す小さなスクリプトを使います。Claude Codeには、スクリプトの作成・実行と、拾えなかった遷移の仕分けを任せます。認証が要る画面には色を付けます。
画面になるフォルダ、ならないフォルダ
画面一覧の元になるのはpageファイルだけです。layoutやloadingは画面を包む部品で、URLを持ちません。URLはフォルダ名から決まりますが、フォルダ名がそのままURLにならない例外が3つあります。
| フォルダ名 | 例 | URLへの影響 |
|---|---|---|
(group) | 例app/(marketing)/page.tsx | URLへの影響()の部分がURLから消え、/になる |
_folder | 例app/blog/_components/Post.tsx | URLへの影響ルートにならない。部品置き場 |
[param] | 例app/blog/[slug]/page.tsx | URLへの影響/blog/my-first-postのような動的URLに一致する |
[...slug]は後続のセグメントすべてに、[[...slug]]は後続なしの/docsにも一致します。@folderは親のlayoutが描く名前付きスロットで、URLの一部ではありません。ここまでが、スクリプトのtoRoute関数で再現する範囲です。
別々の(group)に、同じURLへ解決するpageを置くと、Next.js側でエラーになります。画面一覧にURLの重複が出たら、図の問題ではなくルート定義の衝突を疑ってください。
走査スクリプトの全体
次のスクリプトをプロジェクト直下のscripts/routes.mjsなどに置きます。Node.jsだけで動き、依存パッケージはありません。やっていることは4つです。
スクリプトの処理の流れ
- 1
page.tsxを集める
app/を再帰的に歩き、pageファイルのパスをURLに直します。 - 2
リンクを拾う
<Link href="...">、router.push/router.replace、redirectの文字列リテラルを正規表現で抜きます。 - 3
認証の印を決める
proxy.tsのmatcherに前方一致する画面と、ファイル内で/loginへredirectしている画面に印を付けます。 - 4
Mermaidに出す
画面をノード、遷移をエッジにして
flowchart LRとして出力します。--tableを付けると表になります。
import { readdirSync, readFileSync, statSync, existsSync } from 'node:fs'
import { join, relative, sep } from 'node:path'
const root = process.argv[2] ?? 'app'
const walk = (d) => readdirSync(d).flatMap((n) => {
const p = join(d, n)
return statSync(p).isDirectory() ? walk(p) : [p]
})
const files = walk(root).filter((f) => /\.(tsx|jsx|ts|js)$/.test(f))
// app/ 以下のパスを URL に直す。(group) と _private と @slot は URL に出ない
const toRoute = (f) => {
const segs = relative(root, f).split(sep).slice(0, -1)
const url = segs.filter((s) => !/^\(.*\)$/.test(s) && !s.startsWith('@')).join('/')
return '/' + url
}
const isPage = (f) => /(^|[\\/])page\.(tsx|jsx|ts|js)$/.test(f)
const isPrivate = (f) => relative(root, f).split(sep).some((s) => s.startsWith('_'))
// proxy.ts の matcher を認証対象のプレフィックスとして読む
let guarded = []
for (const p of ['proxy.ts', 'proxy.js', 'middleware.ts']) {
if (!existsSync(p)) continue
const m = readFileSync(p, 'utf8').match(/matcher:\s*(\[[^\]]*\]|'[^']*')/)
if (m) guarded = [...m[1].matchAll(/'([^']+)'/g)].map((x) => x[1].replace(/\/:path\*$/, ''))
}
const re = [
/<Link[^>]*\shref=\{?["'`]([^"'`]+)["'`]/g,
/router\.(?:push|replace)\(\s*["'`]([^"'`]+)["'`]/g,
/redirect\(\s*["'`]([^"'`]+)["'`]/g,
]
const pages = new Map()
const pageFile = new Map()
const edges = []
const dynamic = []
for (const f of files) {
const src = readFileSync(f, 'utf8')
const from = isPage(f) ? toRoute(f) : isPrivate(f) ? '共通部品' : null
if (isPage(f)) pageFile.set(from, f)
if (isPage(f)) pages.set(from, { guarded: guarded.some((g) => from === g || from.startsWith(g + '/')) || /redirect\(\s*['"]\/login/.test(src) })
if (!from) continue
for (const r of re) for (const m of src.matchAll(r)) {
if (m[1].includes('${')) { dynamic.push(`${f}: ${m[1]}`); continue }
edges.push([from, m[1], r === re[2] ? 'redirect' : 'link'])
}
}
const toRegex = (route) => new RegExp('^' + route
.replace(/\[\[\.\.\.[^\]]+\]\]/g, '(?:.*)').replace(/\[\.\.\.[^\]]+\]/g, '.+')
.replace(/\[[^\]]+\]/g, '[^/]+') + '$')
const resolve = (url) => {
if (id.has(url)) return id.get(url)
const hit = [...pages.keys()].find((k) => toRegex(k).test(url))
return hit ? id.get(hit) : undefined
}
const id = new Map([...pages.keys()].map((k, i) => [k, `p${i}`]))
if (process.argv.includes('--table')) {
console.log('| URL | ファイル | 認証 |\n|---|---|---|')
for (const [k, v] of [...pages].sort()) console.log(`| ${k} | ${pageFile.get(k)} | ${v.guarded ? '要' : '-'} |`)
process.exit(0)
}
console.log('flowchart LR')
for (const [k, v] of pages) console.log(` ${id.get(k)}["${k}"]${v.guarded ? ':::auth' : ''}`)
console.log(' common["共通部品"]')
for (const [a, b, kind] of [...new Set(edges.map((e) => e.join('\t')))].map((s) => s.split('\t'))) {
const to = resolve(b.split('?')[0].replace(/#.*/, ''))
if (!to) { console.error(`未解決: ${a} -> ${b}`); continue }
console.log(` ${id.get(a) ?? 'common'} ${kind === 'redirect' ? '-. redirect .->' : '-->'} ${to}`)
}
console.log(' classDef auth fill:#fde,stroke:#c36,stroke-width:2px;')
for (const d of dynamic) console.error('動的 href: ' + d)Mermaidのノードは、IDをp0のような英数字にして、ラベルをダブルクォートで囲んでいます。ラベルに/blog/[slug]のような記号が入るため、クォートで囲む形にしてあります。点線エッジは、Mermaidのリンクに文字を付ける書き方(A-. text .-> B)に合わせました。
サンプルの構成で動かした結果
次のような小さな構成で動かしました(Node.js v20.18.0)。app/_components/Nav.tsxは共通のナビゲーションで、/dashboardへのリンクを持ちます。proxy.tsのmatcherは/dashboardと/settings配下です。
app/
page.tsx
blog/page.tsx
blog/[slug]/page.tsx
(auth)/login/page.tsx
(app)/dashboard/page.tsx
(app)/settings/page.tsx
_components/Nav.tsx
proxy.ts画面一覧はnode scripts/routes.mjs app --tableで出ます。出力はそのままMarkdownの表です。
| URL | ファイル | 認証 |
|---|---|---|
| / | ファイルapp/page.tsx | 認証- |
| /blog | ファイルapp/blog/page.tsx | 認証- |
| /blog/[slug] | ファイルapp/blog/[slug]/page.tsx | 認証- |
| /dashboard | ファイルapp/(app)/dashboard/page.tsx | 認証要 |
| /login | ファイルapp/(auth)/login/page.tsx | 認証- |
| /settings | ファイルapp/(app)/settings/page.tsx | 認証要 |
遷移図はnode scripts/routes.mjs app > route-map.mmdで書き出せます。出力は次のとおりです。
flowchart LR
p0["/dashboard"]:::auth
p1["/settings"]:::auth
p2["/login"]
p3["/blog/[slug]"]
p4["/blog"]
p5["/"]
common["共通部品"]
p0 --> p1
p0 -. redirect .-> p2
p1 -. redirect .-> p2
p2 --> p0
common --> p0
p3 --> p4
p4 --> p3
p5 --> p4
p5 --> p2
classDef auth fill:#fde,stroke:#c36,stroke-width:2px;(auth)や(app)はURLから消え、/loginと/dashboardになっています。バッククォートで囲んだテンプレートリテラルのhrefでも、変数の埋め込みを含まなければ拾えます。/blogの記事リンクは、/blog/helloという具体的なURLを[slug]の画面にひも付けています。
認証が要る画面に印を付ける
classDef authで定義した色を、対象ノードに:::authで付けています。Mermaidでは、classDef 名前 fill:...でスタイルを定義し、ノードにA:::someclassの形で付与できます。
印を付ける根拠は、スクリプトでは2つです。
proxy.tsのmatcherに書かれたパスの前方一致- 画面のファイルに
redirect('/login...')がある
matcherは、Proxyを動かすパスを絞る設定です。matcherを書かないと、Proxyは_next/staticやpublic/の静的ファイルを含む全リクエストで動きます。認証をProxyに置くなら、静的ファイルを除外する書き方を併用する前提です。
このスクリプトの判定は、設計意図を図に写すための近似です。matcherが正規表現を含む場合や、ルートごとのlayoutで認可している場合は、guardedの判定を自分のプロジェクトに合わせて書き換えてください。
拾えない遷移と、その見分け方
正規表現による走査は、書き方次第で取りこぼします。取りこぼしは、標準エラー出力に出して気づける形にしてあります。
| 症状 | 原因 | 対処 |
|---|---|---|
動的 href: ...が出る | 原因テンプレートリテラルのhrefに変数が埋め込まれている | 対処遷移先のURLパターンを手で補う |
未解決: A -> /xが出る | 原因リンク先に対応するpageがない | 対処リンク切れか、route.ts(API)宛てかを確認する |
| 矢印の出元が「共通部品」になる | 原因_componentsの中のLink | 対処どの画面が部品を使うかを人が確認する |
router.back()の遷移が無い | 原因戻る操作は行き先が履歴で決まる | 対処図には入れない |
| モーダルで開く画面が重複して見える | 原因(.)folderのインターセプトルート | 対処通常のルートと別ノードにするか注記する |
/oldから/newへの遷移が図に無い | 原因next.configのredirectsが処理している | 対処設定ファイルを読んで、点線エッジを手で足す |
notFound()の行き先が図に無い | 原因例外を投げてnot-foundの画面を出す | 対処遷移ではなく表示の切り替えとして扱う |
設定ファイルが起こす遷移
next.config.jsのredirectsは、source、destination、permanentを持つ配列を返す関数です。permanentがtrueなら308、falseなら307で、どちらもコードのLinkやrouter.pushには現れません。スクリプトが読むのはapp/の中だけなので、旧URLから新URLへの転送は図に載りません。URLの付け替えをした履歴があるプロジェクトでは、redirectsの中身をClaude Codeに読ませ、sourceからdestinationへの点線エッジとして足させると、図と実際の挙動が合います。
permanentRedirectはredirectと同じ関数形式で、こちらは正規表現のredirect\(に一致しないため拾えません。notFound()は遷移ではなく、そのルートの描画を止めて404の画面を出す関数です。図に矢印を引く対象ではありません。
並列ルートとインターセプトルートの描き分け
@folderは名前付きスロットで、親のlayoutがchildrenと並べて描きます。URLを持たないので、スクリプトは@で始まるフォルダを飛ばして親のURLに寄せます。スロットごとにdefault.jsを置いておくと、ページを再読み込みしたときにスロットの状態を復元できず404になる事態を避けられます。default.jsが無いと、一致しないスロットは404になります。図では@slotを別ノードにせず、親の画面ノードのラベルに「スロット: auth」のように添えておくと、モーダル用の画面が増えて見えません。
インターセプトルートの(.)は同じ階層、(..)は1つ上、(...)はルートのappを基準に、別の画面へのリンクを横取りしてモーダルなどで見せます。基準はファイルシステムではなくルートのセグメントで、@slotのフォルダは数えません。リンクの宛先は通常の画面と同じURLなので、画面一覧の表には出ず、遷移図では1本の矢印に見えます。モーダルで開く遷移は、エッジのラベルに「モーダル」と書いておくと、通常の遷移と区別がつきます。
<Link>はreplaceやprefetchなどのpropを取りますが、遷移先を決めるのはhrefだけです。router.pushの第2引数もスクロールなどの制御なので、これらを走査に含めても図は変わりません。
router.pushやrouter.replaceに、ユーザー入力から作ったURLを渡すのは避けてください。公式の注記にあるとおり、javascript:のURLが実行されるおそれがあります。走査で動的なhrefが見つかったら、図に足すだけでなく、入力検証の点検にも使えます。
Claude Codeに任せる範囲
スクリプトが出す「確定した部分」と、人が判断する「未解決の部分」を分けておくと、Claude Codeへの指示が短くなります。CLAUDE.mdに書く規約の例です。
## 画面遷移図
- `node scripts/routes.mjs app > docs/route-map.mmd` で遷移図を更新する
- `node scripts/routes.mjs app --table` の出力を docs/screens.md に貼る
- 標準エラーに「未解決」「動的 href」が出たら、該当ファイルを読んで
遷移先を推定し、docs/route-map.mmd の末尾に手書きのエッジとして足す
- 認証の印は proxy.ts の matcher と redirect('/login') で決める。
推定で印を付けないスクリプトそのものを作らせるときは、手順の4つを依頼文に並べ、出力の形と取りこぼしの出し方まで指定します。たとえば「app/配下のpageファイルをURLに直し、LinkとrouterとredirectのリテラルをMermaidのflowchartで出す。動的なhrefと解決できないリンクは標準エラーに出す。依存パッケージは使わない」と書きます。出力形式を先に決めておくと、生成されたコードが記事の表や図と同じ形で返ってきます。
最初の1回は、変更を確認してから進めたいので、プランモードで始めると安全です。
claude --permission-mode planプランモードでは、Claude Codeはファイルを読んで計画を示しますが、承認するまで編集しません。計画に問題がなければ承認し、スクリプトの作成と初回の出力を任せます。
更新を定期的に回したい場合は、非対話モードの-pが使えます。
claude -p "scripts/routes.mjs を実行し、未解決のリンクを docs/route-map.mmd に反映して" \
--allowedTools "Read,Edit,Bash"--allowedToolsは、指定したツールを確認なしで使えるようにする指定です。Bashを丸ごと許可すると広い権限になるため、まずは手元のリポジトリで試し、CIに載せるときは許可の範囲を絞り込んでください。
実行結果は、Claude Codeの出力を信じる前にgit diffで確認します。スクリプトの決定的な出力と、Claude Codeが足した推定のエッジは、コミットを分けておくと見分けがつきます。
描画と運用の置き場所
.mmdの中身は、MermaidをサポートするMarkdown環境にそのまま貼れます。図が大きくなったら、subgraphでURLの第1セグメント(/blog、/dashboard)ごとに囲むと読みやすくなります。サブグラフはsubgraph one〜endの形で書き、Mermaidではノードの文字に小文字のendを使うとフローチャートが壊れる点に注意してください(Endと書けば避けられます)。
DBのスキーマ側の図は、DDLからMermaidのER図を起こす手順と同じ方針で作れます。画面遷移図とER図を同じdocs/に置くと、設計の確認が1か所で済みます。
開発サーバーのビルドエラーを直接取りたいときは、Next.js MCPサーバーの設定が別の入口になります。
まとめ
画面一覧はpageファイルから、遷移図はLinkとrouter.pushのリテラルから起こせます。確定できるものはスクリプトで決定的に出し、拾えなかったものは標準エラーに出して人とClaude Codeが埋める。この分担にしておくと、図の信頼度が出力の見た目から判断できます。認証の印は入口のチェックの有無を写したもので、守られているかの保証は、サーバー側の検証で別に確かめる必要があります。
参照した一次ソース
この記事の記述は、次の一次ソースと照合しています。更新時に参照先が変わった場合は一覧も更新します。