Claude Media
Claude Codeで画面遷移図と画面一覧を起こす — Next.jsのルートからMermaidへ

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.tsxURLへの影響()の部分がURLから消え、/になる
_folder例app/blog/_components/Post.tsxURLへの影響ルートにならない。部品置き場
[param]例app/blog/[slug]/page.tsxURLへの影響/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. 1

    page.tsxを集める

    app/を再帰的に歩き、pageファイルのパスをURLに直します。

  2. 2

    リンクを拾う

    <Link href="...">、router.push / router.replace、redirectの文字列リテラルを正規表現で抜きます。

  3. 3

    認証の印を決める

    proxy.tsのmatcherに前方一致する画面と、ファイル内で/loginへredirectしている画面に印を付けます。

  4. 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が埋める。この分担にしておくと、図の信頼度が出力の見た目から判断できます。認証の印は入口のチェックの有無を写したもので、守られているかの保証は、サーバー側の検証で別に確かめる必要があります。

参照した一次ソース

この記事の記述は、次の一次ソースと照合しています。更新時に参照先が変わった場合は一覧も更新します。

  1. nextjs.org/docs/app/getting-started/project-structure
  2. nextjs.org/docs/app/api-reference/file-conventions/proxy
  3. code.claude.com/docs/en/common-workflows
この記事を共有:XはてブLinkedIn